@intentius/chant 0.124.45 → 0.124.46

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 (190) hide show
  1. package/dist/capabilities/aws.paths +148 -0
  2. package/dist/capabilities/base-mappings.json +122 -0
  3. package/dist/capabilities/catalog.d.ts +89 -0
  4. package/dist/capabilities/catalog.d.ts.map +1 -0
  5. package/dist/capabilities/catalog.js +204 -0
  6. package/dist/capabilities/language.d.ts +103 -0
  7. package/dist/capabilities/language.d.ts.map +1 -0
  8. package/dist/capabilities/language.js +335 -0
  9. package/dist/capabilities/matcher.d.ts +65 -0
  10. package/dist/capabilities/matcher.d.ts.map +1 -0
  11. package/dist/capabilities/matcher.js +299 -0
  12. package/dist/capabilities/view.d.ts +207 -0
  13. package/dist/capabilities/view.d.ts.map +1 -0
  14. package/dist/capabilities/view.js +484 -0
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/lint.js +1 -0
  17. package/dist/cli/handlers/declared-graph.d.ts +44 -0
  18. package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
  19. package/dist/cli/handlers/declared-graph.js +87 -0
  20. package/dist/cli/handlers/graph.d.ts +25 -0
  21. package/dist/cli/handlers/graph.d.ts.map +1 -1
  22. package/dist/cli/handlers/graph.js +93 -87
  23. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  24. package/dist/cli/handlers/pr-env.js +2 -1
  25. package/dist/cli/handlers/search-count.d.ts +89 -0
  26. package/dist/cli/handlers/search-count.d.ts.map +1 -0
  27. package/dist/cli/handlers/search-count.js +132 -0
  28. package/dist/cli/handlers/search.d.ts +358 -6
  29. package/dist/cli/handlers/search.d.ts.map +1 -1
  30. package/dist/cli/handlers/search.js +1065 -155
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/main.js +23 -3
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/server.js +3 -0
  35. package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
  36. package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
  37. package/dist/cli/mcp/tools/capabilities.js +27 -0
  38. package/dist/cli/mcp/workspace-tools.d.ts +3 -1
  39. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  40. package/dist/cli/mcp/workspace-tools.js +47 -2
  41. package/dist/cli/parse-args.d.ts.map +1 -1
  42. package/dist/cli/parse-args.js +64 -17
  43. package/dist/cli/registry.d.ts +22 -0
  44. package/dist/cli/registry.d.ts.map +1 -1
  45. package/dist/components/pr-env-ports.d.ts +29 -0
  46. package/dist/components/pr-env-ports.d.ts.map +1 -0
  47. package/dist/components/pr-env-ports.js +77 -0
  48. package/dist/components/pr-env-run.d.ts +5 -21
  49. package/dist/components/pr-env-run.d.ts.map +1 -1
  50. package/dist/components/pr-env-run.js +6 -70
  51. package/dist/components/pr-environment.d.ts +2 -1
  52. package/dist/components/pr-environment.d.ts.map +1 -1
  53. package/dist/components/pr-environment.js +1 -1
  54. package/dist/config-environments.d.ts +92 -0
  55. package/dist/config-environments.d.ts.map +1 -0
  56. package/dist/config-environments.js +102 -0
  57. package/dist/config.d.ts +24 -84
  58. package/dist/config.d.ts.map +1 -1
  59. package/dist/config.js +9 -96
  60. package/dist/graph-detail.d.ts.map +1 -1
  61. package/dist/graph-detail.js +15 -3
  62. package/dist/graph-effective.d.ts +33 -2
  63. package/dist/graph-effective.d.ts.map +1 -1
  64. package/dist/graph-effective.js +215 -68
  65. package/dist/graph-ir.d.ts +23 -0
  66. package/dist/graph-ir.d.ts.map +1 -1
  67. package/dist/graph-ir.js +130 -0
  68. package/dist/graph-sub-template.d.ts +11 -0
  69. package/dist/graph-sub-template.d.ts.map +1 -0
  70. package/dist/graph-sub-template.js +19 -0
  71. package/dist/lexicon.d.ts +9 -0
  72. package/dist/lexicon.d.ts.map +1 -1
  73. package/dist/lifecycle/observe.d.ts +28 -0
  74. package/dist/lifecycle/observe.d.ts.map +1 -1
  75. package/dist/lifecycle/observe.js +23 -1
  76. package/dist/lint/rule.d.ts +6 -0
  77. package/dist/lint/rule.d.ts.map +1 -1
  78. package/dist/live-endpoint.d.ts +1 -1
  79. package/dist/live-endpoint.d.ts.map +1 -1
  80. package/dist/live-endpoint.js +1 -1
  81. package/dist/observation.d.ts +48 -1
  82. package/dist/observation.d.ts.map +1 -1
  83. package/dist/observation.js +21 -5
  84. package/dist/op/activities/activity-contracts.d.ts +4 -4
  85. package/dist/wave-runtime.mjs +250 -249
  86. package/dist/workspace/capabilities-contract.d.ts +66 -0
  87. package/dist/workspace/capabilities-contract.d.ts.map +1 -0
  88. package/dist/workspace/capabilities-contract.js +107 -0
  89. package/dist/workspace/capabilities.schema.json +229 -0
  90. package/dist/workspace/compose-graph.d.ts +9 -0
  91. package/dist/workspace/compose-graph.d.ts.map +1 -1
  92. package/dist/workspace/compose-graph.js +3 -1
  93. package/dist/workspace/conformance/index.d.ts +1 -1
  94. package/dist/workspace/conformance/index.d.ts.map +1 -1
  95. package/dist/workspace/conformance/index.js +39 -2
  96. package/dist/workspace/graph-cache.d.ts +8 -0
  97. package/dist/workspace/graph-cache.d.ts.map +1 -1
  98. package/dist/workspace/graph-cache.js +27 -3
  99. package/dist/workspace/graph-cli.d.ts +39 -2
  100. package/dist/workspace/graph-cli.d.ts.map +1 -1
  101. package/dist/workspace/graph-cli.js +66 -24
  102. package/dist/workspace/graph.schema.json +148 -0
  103. package/dist/workspace/kind-readers.d.ts +3 -0
  104. package/dist/workspace/kind-readers.d.ts.map +1 -1
  105. package/dist/workspace/kind-readers.js +7 -1
  106. package/dist/workspace/member-commands.d.ts +10 -1
  107. package/dist/workspace/member-commands.d.ts.map +1 -1
  108. package/dist/workspace/member-commands.js +17 -1
  109. package/dist/workspace/nested-graph.d.ts +2 -0
  110. package/dist/workspace/nested-graph.d.ts.map +1 -1
  111. package/dist/workspace/nested-graph.js +32 -1
  112. package/dist/workspace/reason-codes.d.ts +10 -0
  113. package/dist/workspace/reason-codes.d.ts.map +1 -1
  114. package/dist/workspace/reason-codes.js +12 -0
  115. package/dist/workspace/search-cli.d.ts +204 -0
  116. package/dist/workspace/search-cli.d.ts.map +1 -0
  117. package/dist/workspace/search-cli.js +466 -0
  118. package/dist/workspace/search-contract.d.ts +56 -0
  119. package/dist/workspace/search-contract.d.ts.map +1 -0
  120. package/dist/workspace/search-contract.js +70 -0
  121. package/dist/workspace/search.schema.json +822 -0
  122. package/package.json +1 -1
  123. package/src/__snapshots__/okf.test.ts.snap +10 -0
  124. package/src/capabilities/aws.paths +148 -0
  125. package/src/capabilities/base-mappings.json +122 -0
  126. package/src/capabilities/catalog.ts +230 -0
  127. package/src/capabilities/language.test.ts +191 -0
  128. package/src/capabilities/language.ts +373 -0
  129. package/src/capabilities/matcher.ts +315 -0
  130. package/src/capabilities/view-compose.test.ts +76 -0
  131. package/src/capabilities/view.ts +631 -0
  132. package/src/cli/commands/lint.ts +1 -0
  133. package/src/cli/handlers/declared-graph.ts +99 -0
  134. package/src/cli/handlers/graph.test.ts +94 -3
  135. package/src/cli/handlers/graph.ts +123 -96
  136. package/src/cli/handlers/pr-env.ts +1 -2
  137. package/src/cli/handlers/search-build-roots.test.ts +144 -0
  138. package/src/cli/handlers/search-count.test.ts +191 -0
  139. package/src/cli/handlers/search-count.ts +165 -0
  140. package/src/cli/handlers/search-json.test.ts +333 -0
  141. package/src/cli/handlers/search-live.test.ts +387 -0
  142. package/src/cli/handlers/search-paths.test.ts +235 -0
  143. package/src/cli/handlers/search.ts +1312 -166
  144. package/src/cli/main.test.ts +28 -3
  145. package/src/cli/main.ts +23 -3
  146. package/src/cli/mcp/server.test.ts +3 -3
  147. package/src/cli/mcp/server.ts +3 -0
  148. package/src/cli/mcp/tools/capabilities.ts +32 -0
  149. package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
  150. package/src/cli/mcp/workspace-tools.ts +47 -2
  151. package/src/cli/parse-args.ts +52 -13
  152. package/src/cli/registry.ts +22 -0
  153. package/src/components/pr-env-ports.ts +89 -0
  154. package/src/components/pr-env-run-bundle.test.ts +54 -0
  155. package/src/components/pr-env-run.ts +5 -76
  156. package/src/components/pr-environment.ts +2 -1
  157. package/src/config-environments.ts +133 -0
  158. package/src/config.ts +37 -124
  159. package/src/graph-detail.ts +15 -3
  160. package/src/graph-effective.ts +226 -75
  161. package/src/graph-ir.test.ts +163 -0
  162. package/src/graph-ir.ts +147 -0
  163. package/src/graph-sub-template.ts +22 -0
  164. package/src/lexicon.ts +10 -0
  165. package/src/lifecycle/observe.test.ts +27 -1
  166. package/src/lifecycle/observe.ts +43 -1
  167. package/src/lint/rule.ts +6 -0
  168. package/src/live-endpoint.ts +1 -1
  169. package/src/observation.test.ts +25 -0
  170. package/src/observation.ts +69 -5
  171. package/src/workspace/capabilities-contract.test.ts +88 -0
  172. package/src/workspace/capabilities-contract.ts +132 -0
  173. package/src/workspace/capabilities.schema.json +229 -0
  174. package/src/workspace/compose-graph.ts +10 -1
  175. package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
  176. package/src/workspace/conformance/index.ts +31 -2
  177. package/src/workspace/graph-cache.ts +27 -3
  178. package/src/workspace/graph-cli.ts +96 -24
  179. package/src/workspace/graph.schema.json +148 -0
  180. package/src/workspace/kind-readers.e2e.test.ts +28 -0
  181. package/src/workspace/kind-readers.test.ts +1 -1
  182. package/src/workspace/kind-readers.ts +7 -1
  183. package/src/workspace/member-commands.ts +22 -1
  184. package/src/workspace/nested-graph.ts +32 -1
  185. package/src/workspace/reason-codes.test.ts +7 -0
  186. package/src/workspace/reason-codes.ts +12 -0
  187. package/src/workspace/search-cli.e2e.test.ts +305 -0
  188. package/src/workspace/search-cli.ts +591 -0
  189. package/src/workspace/search-contract.ts +95 -0
  190. package/src/workspace/search.schema.json +822 -0
package/src/graph-ir.ts CHANGED
@@ -5,9 +5,12 @@ import { type Declarable, isDeclarable } from "./declarable";
5
5
  import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
6
6
  import { getProvenance } from "./provenance";
7
7
  import { INTRINSIC_MARKER } from "./intrinsic";
8
+ import { subTemplateRefs } from "./graph-sub-template";
8
9
  import type { ResourceMetadata } from "./lexicon";
9
10
  import type { UnobservedEntity } from "./observation";
10
11
 
12
+ export { subTemplateRefs };
13
+
11
14
  /**
12
15
  * Graph IR — the engine-neutral, lint-gated representation of a project's
13
16
  * resolved infrastructure graph. Painters (mermaid, graphviz, custom SVG) and
@@ -35,6 +38,21 @@ export interface AttrRefEnvelope {
35
38
  $ref: string;
36
39
  }
37
40
 
41
+ /**
42
+ * A CloudFormation `Fn::Sub` in an attribute projection (#4373), in place of
43
+ * `{ $intrinsic: true }`. `$sub` is the template exactly as it serializes, so
44
+ * `${assets.Arn}/*` names the producer and attribute a reader would otherwise
45
+ * have to guess. `$vars` is the two-argument form's variable map, each value
46
+ * projected like any other attr. Every `${Name}` or `${Name.Attr}` in the
47
+ * template that names a node, and every reference in `$vars`, is also an edge.
48
+ * `${!Literal}` (CloudFormation's escape), pseudo-parameters (`${AWS::Region}`)
49
+ * and names `$vars` binds are not.
50
+ */
51
+ export interface SubEnvelope {
52
+ $sub: string;
53
+ $vars?: Record<string, unknown>;
54
+ }
55
+
38
56
  /** One resource in the graph. */
39
57
  export interface IRNode {
40
58
  /** Logical name (the export name, or composite-expanded name). */
@@ -465,6 +483,13 @@ export interface GraphIR {
465
483
  * the passes produced, not a list maintained by hand.
466
484
  */
467
485
  derivedAttrs?: Record<string, string[]>;
486
+ /**
487
+ * With `chant graph --format ir --capability-view` (#4378): the
488
+ * lexicon-neutral view capability rules read, built by this chant through
489
+ * the project's own lexicon mappings and adapters. A `ViewDocument`
490
+ * (`capabilities/view.ts`). Absent unless asked for.
491
+ */
492
+ capabilityView?: unknown;
468
493
  }
469
494
 
470
495
  /** A node is anything that serializes to a resource — not a property or output. */
@@ -499,6 +524,80 @@ function refIntrinsicTarget(value: unknown): string | undefined {
499
524
  return undefined;
500
525
  }
501
526
 
527
+ /** A CloudFormation `Fn::Sub`: its template, and the variable map of the two-argument form. */
528
+ interface SubForm {
529
+ template: string;
530
+ vars?: Record<string, unknown>;
531
+ }
532
+
533
+ /** Read `{ "Fn::Sub": "..." }` or `{ "Fn::Sub": ["...", { ... }] }`, or undefined. */
534
+ function subForm(json: unknown): SubForm | undefined {
535
+ if (json === null || typeof json !== "object" || Array.isArray(json)) return undefined;
536
+ const keys = Object.keys(json);
537
+ if (keys.length !== 1 || keys[0] !== "Fn::Sub") return undefined;
538
+ const arg = (json as Record<string, unknown>)["Fn::Sub"];
539
+ if (typeof arg === "string") return { template: arg };
540
+ if (Array.isArray(arg) && typeof arg[0] === "string") {
541
+ const vars = arg[1];
542
+ return vars !== null && typeof vars === "object" && !Array.isArray(vars)
543
+ ? { template: arg[0], vars: vars as Record<string, unknown> }
544
+ : { template: arg[0] };
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ /** The `Fn::Sub` an intrinsic serializes to (the aws `Sub` tag), or undefined. */
550
+ function subIntrinsicForm(value: unknown): SubForm | undefined {
551
+ try {
552
+ return subForm((value as { toJSON?: () => unknown }).toJSON?.());
553
+ } catch {
554
+ // An AttrRef inside whose logical name is not set yet: no template to read.
555
+ return undefined;
556
+ }
557
+ }
558
+
559
+ /** False when an intrinsic's `toJSON()` throws (an AttrRef inside it has no logical name yet). */
560
+ function serializes(value: unknown): boolean {
561
+ try {
562
+ (value as { toJSON?: () => unknown }).toJSON?.();
563
+ return true;
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * The logical names serialized CloudFormation JSON references: `{ Ref }`,
571
+ * `{ "Fn::GetAtt" }` in either encoding, and a nested `Fn::Sub`. Reads a
572
+ * two-argument `Sub`'s variable map after `toJSON()` has turned its live
573
+ * references into JSON.
574
+ */
575
+ function cfnJsonRefNames(json: unknown, out: string[]): void {
576
+ if (json === null || typeof json !== "object") return;
577
+ if (Array.isArray(json)) {
578
+ for (const item of json) cfnJsonRefNames(item, out);
579
+ return;
580
+ }
581
+ const obj = json as Record<string, unknown>;
582
+ if (typeof obj.Ref === "string") {
583
+ out.push(obj.Ref);
584
+ return;
585
+ }
586
+ const getAtt = obj["Fn::GetAtt"];
587
+ if (getAtt !== undefined) {
588
+ const id = Array.isArray(getAtt) ? getAtt[0] : typeof getAtt === "string" ? getAtt.split(".")[0] : undefined;
589
+ if (typeof id === "string") out.push(id);
590
+ return;
591
+ }
592
+ const sub = subForm(obj);
593
+ if (sub) {
594
+ for (const r of subTemplateRefs(sub.template, new Set(Object.keys(sub.vars ?? {})))) out.push(r.name);
595
+ if (sub.vars) cfnJsonRefNames(Object.values(sub.vars), out);
596
+ return;
597
+ }
598
+ for (const v of Object.values(obj)) cfnJsonRefNames(v, out);
599
+ }
600
+
502
601
  /** Resolve the producer (logical name) an AttrRef points at. */
503
602
  function refTarget(ref: AttrRef, reverse: Map<object, string>): string | undefined {
504
603
  // Nested refs (under `props`) aren't assigned a logical name by resolveAttrRefs,
@@ -545,6 +644,9 @@ function project(value: unknown, seen: Set<unknown>, reverse: Map<object, string
545
644
  // Ref to a non-node) stay opaque.
546
645
  const to = refIntrinsicTarget(value);
547
646
  if (to && nodeIds.has(to)) return { $ref: to } satisfies AttrRefEnvelope;
647
+ // A Sub keeps its template, so a reader sees which node and attribute it names (#4373).
648
+ const sub = subIntrinsicForm(value);
649
+ if (sub) return subEnvelope(sub, seen, reverse, nodeIds);
548
650
  return { $intrinsic: true };
549
651
  }
550
652
 
@@ -568,6 +670,14 @@ function project(value: unknown, seen: Set<unknown>, reverse: Map<object, string
568
670
  return out;
569
671
  }
570
672
 
673
+ // A Sub written as plain CloudFormation JSON reads the same as the `Sub` tag's.
674
+ const plainSub = subForm(value);
675
+ if (plainSub) {
676
+ const out = subEnvelope(plainSub, seen, reverse, nodeIds);
677
+ seen.delete(value);
678
+ return out;
679
+ }
680
+
571
681
  const out: Record<string, unknown> = {};
572
682
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
573
683
  const p = project(v, seen, reverse, nodeIds);
@@ -577,6 +687,17 @@ function project(value: unknown, seen: Set<unknown>, reverse: Map<object, string
577
687
  return out;
578
688
  }
579
689
 
690
+ /** Project a {@link SubForm} into its {@link SubEnvelope}. */
691
+ function subEnvelope(sub: SubForm, seen: Set<unknown>, reverse: Map<object, string>, nodeIds: Set<string>): SubEnvelope {
692
+ if (!sub.vars) return { $sub: sub.template };
693
+ const vars: Record<string, unknown> = {};
694
+ for (const [k, v] of Object.entries(sub.vars)) {
695
+ const p = project(v, seen, reverse, nodeIds);
696
+ if (p !== undefined) vars[k] = p;
697
+ }
698
+ return { $sub: sub.template, $vars: vars };
699
+ }
700
+
580
701
  // `references` and `stack` are the two duck-typed channels above
581
702
  // ({@link entityReferences}, {@link entityStack}). They are metadata about the
582
703
  // entity's place in the graph, not declared configuration, and both are read
@@ -645,6 +766,9 @@ function collectEdges(
645
766
  });
646
767
  }
647
768
  const seen = new Set<unknown>();
769
+ const edgeTo = (to: string, viaAttr: string): void => {
770
+ if (to !== from && nodeIds.has(to)) edges.push({ from, to, kind: "ref", viaAttr });
771
+ };
648
772
  const visit = (value: unknown, viaAttr: string): void => {
649
773
  if (value === null || typeof value !== "object") return;
650
774
  if (isAttrRefLike(value)) {
@@ -659,8 +783,31 @@ function collectEdges(
659
783
  // — resolve it to an edge instead of dropping it as an opaque intrinsic (#513).
660
784
  const to = refIntrinsicTarget(value);
661
785
  if (to && to !== from && nodeIds.has(to)) edges.push({ from, to, kind: "ref", viaAttr });
786
+ // A Sub's interpolations are references too (#4373): each `${Name}` or
787
+ // `${Name.Attr}` naming a node, and each reference in a variable map.
788
+ const sub = subIntrinsicForm(value);
789
+ if (sub) {
790
+ const names = subTemplateRefs(sub.template, new Set(Object.keys(sub.vars ?? {}))).map((r) => r.name);
791
+ if (sub.vars) cfnJsonRefNames(Object.values(sub.vars), names);
792
+ for (const name of names) edgeTo(name, viaAttr);
793
+ }
794
+ // The live values the aws `Sub` tag interpolated. Covers an AttrRef whose
795
+ // logical name was not set, where `toJSON()` throws and leaves no
796
+ // template to read above. Any other intrinsic stays opaque.
797
+ const interpolated = (value as { values?: unknown }).values;
798
+ if (Array.isArray(interpolated) && (sub || !serializes(value))) {
799
+ for (const v of interpolated) visit(v, viaAttr);
800
+ }
662
801
  return;
663
802
  }
803
+ // A Sub written as plain CloudFormation JSON in props: the template's names
804
+ // here, then the variable map's live values through the walk below.
805
+ const plainSub = subForm(value);
806
+ if (plainSub) {
807
+ for (const r of subTemplateRefs(plainSub.template, new Set(Object.keys(plainSub.vars ?? {})))) {
808
+ edgeTo(r.name, viaAttr);
809
+ }
810
+ }
664
811
  // A whole node held in props or an enumerable field (the sql lexicon's
665
812
  // `dependsOn`) is a reference to it: draw the edge, and do not walk its
666
813
  // config, whose refs belong to that node's own edges (#3257). Checked
@@ -0,0 +1,22 @@
1
+ // Kept apart from ./graph-ir so ./graph-detail, which runs in the browser
2
+ // bundle, can read a Sub template without pulling in node:path.
3
+
4
+ /**
5
+ * The names a `Sub` template interpolates (#4373): `${Name}` and
6
+ * `${Name.Attr}`, in order. Skips CloudFormation's `${!Literal}` escape,
7
+ * pseudo-parameters (`${AWS::Region}`, anything with `::`), and names the
8
+ * two-argument form's variable map binds, which are not resources.
9
+ */
10
+ export function subTemplateRefs(
11
+ template: string,
12
+ bound: ReadonlySet<string> = new Set(),
13
+ ): Array<{ name: string; attr?: string }> {
14
+ const out: Array<{ name: string; attr?: string }> = [];
15
+ for (const match of template.matchAll(/\$\{([^}]*)\}/g)) {
16
+ const token = match[1].trim();
17
+ if (!token || token.startsWith("!") || token.includes("::") || bound.has(token)) continue;
18
+ const dot = token.indexOf(".");
19
+ out.push(dot > 0 ? { name: token.slice(0, dot), attr: token.slice(dot + 1) } : { name: token });
20
+ }
21
+ return out;
22
+ }
package/src/lexicon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CapabilityPack } from "./capabilities/view";
1
2
  import type { Serializer } from "./serializer";
2
3
  import type { Declarable } from "./declarable";
3
4
  import type { LintRule } from "./lint/rule";
@@ -2082,6 +2083,15 @@ export interface LexiconPlugin {
2082
2083
  */
2083
2084
  referenceCatalog?: ReferenceCatalog;
2084
2085
 
2086
+ /**
2087
+ * This lexicon's capability pack (#4375): its mapping onto the
2088
+ * lexicon-neutral view the capability rules read (labels, edge names,
2089
+ * attributes, policy documents, inline blocks, link resources), as data,
2090
+ * plus an adapter for what the mapping can't say as data. `chant search`'s
2091
+ * `cap:` terms and `--capabilities` read it. Opt-in.
2092
+ */
2093
+ capabilityPack?: CapabilityPack;
2094
+
2085
2095
  /**
2086
2096
  * Enrich live IR node attributes for edge reconstruction (#784). Returns
2087
2097
  * `nodeId → attributes` with cross-resource references resolved to the
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { observeResources, collectAmbient } from "./observe";
2
+ import { observeResources, collectAmbient, carryAnnotations } from "./observe";
3
3
  import { observation } from "../observation";
4
4
  import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
5
5
  import type { BuildResult } from "../build";
@@ -59,6 +59,32 @@ describe("observeResources", () => {
59
59
  expect(Object.keys(observations[0].resources)).toEqual(["web-vpc"]);
60
60
  });
61
61
 
62
+ it("returns what a lexicon learned about an absent entity as annotations, outside the observations (#4372)", async () => {
63
+ const plugins = [
64
+ {
65
+ name: "aws",
66
+ serializer: {} as ObservationLexicon["serializer"],
67
+ describeResources: async () =>
68
+ observation({}, undefined, undefined, undefined, undefined, undefined, { "web-vpc": { planAction: "create" } }),
69
+ } as unknown as ObservationLexicon,
70
+ ];
71
+ const { observations, annotations } = await observeResources("prod", plugins, mockBuild());
72
+ // Absent: no observation row anywhere, so nothing that classifies sees it.
73
+ expect(observations.flatMap((o) => Object.keys(o.resources))).toEqual([]);
74
+ expect(annotations).toEqual({ "web-vpc": { planAction: "create" } });
75
+ });
76
+
77
+ it("carryAnnotations sets attributes by id and touches nothing else (#4372)", () => {
78
+ const nodes = [
79
+ { id: "a", kind: "K", lexicon: "x", attrs: { _status: "accent", name: "a" } },
80
+ { id: "b", kind: "K", lexicon: "x", attrs: { _status: "good" } },
81
+ ];
82
+ const out = carryAnnotations(nodes, { a: { planAction: "create" } });
83
+ expect(out[0]).toEqual({ id: "a", kind: "K", lexicon: "x", attrs: { _status: "accent", name: "a", planAction: "create" } });
84
+ expect(out[1]).toBe(nodes[1]);
85
+ expect(carryAnnotations(nodes, undefined)).toBe(nodes);
86
+ });
87
+
62
88
  it("passes the declared entity names for scoping", async () => {
63
89
  let names: string[] = [];
64
90
  const plugins = [awsPlugin(({ entityNames }) => { names = entityNames; return {}; })];
@@ -22,6 +22,7 @@ import {
22
22
  unobservedAll,
23
23
  formatUnobserved,
24
24
  type NormalizedObservation,
25
+ type RootRead,
25
26
  } from "../observation";
26
27
  import { zeroResourcesWarning } from "../live-endpoint";
27
28
  import { unqualifiedKey } from "./identity";
@@ -39,6 +40,40 @@ export interface ObserveResult {
39
40
  * answer, not ahead of it.
40
41
  */
41
42
  notes: string[];
43
+ /**
44
+ * Facts about declared entities beside their verdict (#4372), keyed like
45
+ * the observations' resources (stack-qualified where those are). A planned
46
+ * create is the case: absent, with the plan's attributes. Never part of
47
+ * `observations`, so nothing that classifies or paints presence sees it;
48
+ * {@link carryAnnotations} puts it on the declared nodes of an overlay.
49
+ */
50
+ annotations?: Record<string, Record<string, unknown>>;
51
+ /**
52
+ * Which read answered each declared entity (#2267), keyed like the
53
+ * observations' resources: `"state"`, `"live"`, `"plan"` or `"live+plan"`
54
+ * from the terraform lexicon. Present only for lexicons that report it.
55
+ */
56
+ sources?: Record<string, string>;
57
+ /** Which read answered each root, and why it fell back (structured, from the lexicon). */
58
+ rootReads?: Record<string, RootRead>;
59
+ }
60
+
61
+ /**
62
+ * Put each entity's annotations (#4372) on its node, by id. Only attributes
63
+ * are touched: `_status`, `physicalId` and `ownership` stay what the overlay
64
+ * decided, so a planned create is still painted pending and still absent to
65
+ * every consumer that asks. An annotation wins a collision with a declared
66
+ * attribute, the same precedence an observed attribute has in the overlay.
67
+ */
68
+ export function carryAnnotations<N extends { id: string; attrs: Record<string, unknown> }>(
69
+ nodes: N[],
70
+ annotations: Record<string, Record<string, unknown>> | undefined,
71
+ ): N[] {
72
+ if (!annotations || Object.keys(annotations).length === 0) return nodes;
73
+ return nodes.map((n) => {
74
+ const a = annotations[n.id];
75
+ return a && Object.keys(a).length > 0 ? { ...n, attrs: { ...n.attrs, ...a } } : n;
76
+ });
42
77
  }
43
78
 
44
79
  /**
@@ -57,6 +92,7 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
57
92
  queried: q(obs.queried),
58
93
  // Which read answered (#2267) is per entity, so it re-keys with them.
59
94
  sources: q(obs.sources),
95
+ ...(obs.annotations ? { annotations: q(obs.annotations) } : {}),
60
96
  notes: obs.notes,
61
97
  // Exports are already keyed by stack (#1279); nothing to qualify.
62
98
  ...(obs.stackExports ? { stackExports: obs.stackExports } : {}),
@@ -131,6 +167,9 @@ export async function observeResources(
131
167
  const warnings: string[] = [];
132
168
  const errors: string[] = [];
133
169
  const notes: string[] = [];
170
+ const annotations: Record<string, Record<string, unknown>> = {};
171
+ const sources: Record<string, string> = {};
172
+ const rootReads: Record<string, RootRead> = {};
134
173
 
135
174
  for (const plugin of plugins) {
136
175
  if (!plugin.describeResources) continue;
@@ -230,6 +269,9 @@ export async function observeResources(
230
269
  const line = `[${plugin.name}] ${note}`;
231
270
  if (!notes.includes(line)) notes.push(line);
232
271
  }
272
+ Object.assign(annotations, observed.annotations ?? {});
273
+ Object.assign(sources, observed.sources);
274
+ Object.assign(rootReads, observed.rootReads ?? {});
233
275
  // What the estate depends on but does not declare (#1273). Read after the
234
276
  // managed resources, because the declared observation is the closure's
235
277
  // roots — there is nothing to reference out from until it exists.
@@ -299,7 +341,7 @@ export async function observeResources(
299
341
  }
300
342
  }
301
343
 
302
- return { observations, warnings, errors, notes };
344
+ return { observations, warnings, errors, notes, annotations, ...(Object.keys(sources).length > 0 ? { sources } : {}), ...(Object.keys(rootReads).length > 0 ? { rootReads } : {}) };
303
345
  }
304
346
 
305
347
  /**
package/src/lint/rule.ts CHANGED
@@ -45,6 +45,12 @@ export interface LintDiagnostic {
45
45
  severity: Severity;
46
46
  /** Human-readable message */
47
47
  message: string;
48
+ /**
49
+ * The entity a post-synth check reported against, when it named one: the
50
+ * same name the message leads with in brackets, and the graph node's id
51
+ * (behold#919). Absent for a source-file rule.
52
+ */
53
+ entity?: string;
48
54
  /** Optional fix that can be applied */
49
55
  fix?: LintFix;
50
56
  }
@@ -35,7 +35,7 @@
35
35
  * `--live --env floci` read against azure silently went to real Azure.
36
36
  */
37
37
 
38
- import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
38
+ import { environmentEndpoint, type EnvironmentDeclaration } from "./config-environments";
39
39
  import { emulatorsOf, endpointEnvVars, type EmulatorDeclaration } from "./op/emulator-lifecycle";
40
40
 
41
41
  /**
@@ -221,6 +221,31 @@ describe("observeEntities harness (#1201)", () => {
221
221
  });
222
222
  });
223
223
 
224
+ test("an absent entity's annotations are collected, and it stays in neither map (#4372)", async () => {
225
+ const result = await observeEntities(
226
+ [entity("a"), entity("b"), entity("c")],
227
+ adapterOf({
228
+ a: { present: meta() },
229
+ b: { absent: true, annotations: { planAction: "create", planAfterUnknown: ["id"] } },
230
+ c: { absent: true, annotations: {} },
231
+ }),
232
+ );
233
+ expect(Object.keys(result.resources)).toEqual(["a"]);
234
+ expect(result.unobserved).toBeUndefined();
235
+ expect(result.annotations).toEqual({ b: { planAction: "create", planAfterUnknown: ["id"] } });
236
+ // Through the normalizer and a merge with another part, unchanged.
237
+ const merged = mergeObservations([normalizeObservation(result), normalizeObservation({ other: meta() })]);
238
+ expect(merged.annotations).toEqual({ b: { planAction: "create", planAfterUnknown: ["id"] } });
239
+ expect(Object.keys(merged.resources).sort()).toEqual(["a", "other"]);
240
+ });
241
+
242
+ test("no annotations means no annotations key, on the envelope and the normalized form", async () => {
243
+ const result = await observeEntities([entity("b")], adapterOf({ b: { absent: true } }));
244
+ expect("annotations" in result).toBe(false);
245
+ expect("annotations" in normalizeObservation(result)).toBe(false);
246
+ expect("annotations" in mergeObservations([normalizeObservation(result)])).toBe(false);
247
+ });
248
+
224
249
  test("collects the queried address from every variant — the absent one especially (#1620)", async () => {
225
250
  const result = await observeEntities(
226
251
  [entity("a"), entity("b"), entity("c")],
@@ -149,6 +149,31 @@ export interface ObservationResult {
149
149
  * before this field an observation gave it no way to tell them apart.
150
150
  */
151
151
  sources?: Record<string, string>;
152
+ /**
153
+ * Facts the read learned about an entity beside its verdict (#4372), keyed
154
+ * by chant entity name, as attribute name -> value. The case it exists for
155
+ * is a Terraform plan's planned create: the plan says the block will be
156
+ * created and which of its values stay unknown until apply, but the block
157
+ * has no row in the plan's prior state, so it is OBSERVED-ABSENT, and
158
+ * absence is spelled "in neither map". Without this map those facts had
159
+ * nowhere to go.
160
+ *
161
+ * Additive metadata over the tri-state, like {@link queried} and
162
+ * {@link sources}: classification never reads it, ownership never comes
163
+ * from it, and an entity with annotations and no `resources` row is still
164
+ * absent. A renderer or search carries the attributes onto the declared
165
+ * node (`carryAnnotations` in ./lifecycle/observe.ts), where they read like
166
+ * any other attribute without making the node present.
167
+ */
168
+ annotations?: Record<string, Record<string, unknown>>;
169
+ /**
170
+ * Which read answered each root, and why it fell back when it did (#4384
171
+ * follow-up), keyed by a lexicon-chosen root key (terraform uses
172
+ * `terraform.roots.<root>`). Structured beside {@link notes}, which say the
173
+ * same thing in prose: a consumer reads this field and never matches note
174
+ * text, so rewording a note cannot change what it reports.
175
+ */
176
+ rootReads?: Record<string, RootRead>;
152
177
  /**
153
178
  * Notices about the read as a whole, not about any one entity (#1265) —
154
179
  * "the ownership filter could not be applied on this surface" is the
@@ -169,6 +194,20 @@ export interface ObservationResult {
169
194
  stackExports?: Record<string, Record<string, unknown>>;
170
195
  }
171
196
 
197
+ /**
198
+ * The read a root was asked for and the one that answered. `depth` is the read
199
+ * asked for (`"plan"` for a plan-depth root, `"live+plan"` for a live root at
200
+ * the plan depth). `fellBackTo` is set only when that read failed: the read that
201
+ * answered instead (`"live"` is live-plan alone, for a live root), or
202
+ * `"declared"` when none did.
203
+ * `reason` is a short machine-stable cause, not prose to match on.
204
+ */
205
+ export interface RootRead {
206
+ depth: string;
207
+ fellBackTo?: "state" | "live" | "declared";
208
+ reason?: string;
209
+ }
210
+
172
211
  /**
173
212
  * What `describeResources()` may return: the bare map (pre-#1089, still valid —
174
213
  * "I looked at everything") or the {@link ObservationResult} envelope.
@@ -183,6 +222,10 @@ export interface NormalizedObservation {
183
222
  queried: Record<string, string>;
184
223
  /** Which read answered, per entity name (#2267). Empty when the lexicon reported none. */
185
224
  sources: Record<string, string>;
225
+ /** Facts beside the verdict, per entity name (#4372). Absent when the lexicon reported none. */
226
+ annotations?: Record<string, Record<string, unknown>>;
227
+ /** Which read answered each root, structured. Absent when the lexicon reported none. */
228
+ rootReads?: Record<string, RootRead>;
186
229
  /** Run-level notices (#1265), distinct. Empty when the lexicon reported none. */
187
230
  notes: string[];
188
231
  /** Per-stack exports (#1279), keyed by stack name. Absent when the lexicon reported none. */
@@ -209,6 +252,8 @@ export function observation(
209
252
  notes?: string[],
210
253
  stackExports?: Record<string, Record<string, unknown>>,
211
254
  sources?: Record<string, string>,
255
+ annotations?: Record<string, Record<string, unknown>>,
256
+ rootReads?: Record<string, RootRead>,
212
257
  ): ObservationResult {
213
258
  return {
214
259
  observation: "v1",
@@ -216,6 +261,8 @@ export function observation(
216
261
  ...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
217
262
  ...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
218
263
  ...(sources && Object.keys(sources).length > 0 ? { sources } : {}),
264
+ ...(annotations && Object.keys(annotations).length > 0 ? { annotations } : {}),
265
+ ...(rootReads && Object.keys(rootReads).length > 0 ? { rootReads } : {}),
219
266
  ...(notes && notes.length > 0 ? { notes } : {}),
220
267
  ...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
221
268
  };
@@ -236,6 +283,8 @@ export function normalizeObservation(value: DescribeResourcesResult | undefined)
236
283
  queried: value.queried ?? {},
237
284
  sources: value.sources ?? {},
238
285
  notes: [...new Set(value.notes ?? [])],
286
+ ...(value.annotations && Object.keys(value.annotations).length > 0 ? { annotations: value.annotations } : {}),
287
+ ...(value.rootReads && Object.keys(value.rootReads).length > 0 ? { rootReads: value.rootReads } : {}),
239
288
  ...(value.stackExports && Object.keys(value.stackExports).length > 0 ? { stackExports: value.stackExports } : {}),
240
289
  };
241
290
  }
@@ -287,11 +336,16 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
287
336
  // is one note (#1265).
288
337
  const notes = new Set<string>();
289
338
  const stackExports: Record<string, Record<string, unknown>> = {};
339
+ // Annotations (#4372) merge like `sources`: per entity, disjoint parts.
340
+ const annotations: Record<string, Record<string, unknown>> = {};
341
+ const rootReads: Record<string, RootRead> = {};
290
342
  for (const part of parts) {
291
343
  Object.assign(resources, part.resources);
292
344
  Object.assign(unobserved, part.unobserved);
293
345
  Object.assign(queried, part.queried);
294
346
  Object.assign(sources, part.sources);
347
+ Object.assign(annotations, part.annotations ?? {});
348
+ Object.assign(rootReads, part.rootReads ?? {});
295
349
  for (const n of part.notes) notes.add(n);
296
350
  Object.assign(stackExports, part.stackExports ?? {});
297
351
  }
@@ -304,6 +358,8 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
304
358
  queried,
305
359
  sources,
306
360
  notes: [...notes],
361
+ ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
362
+ ...(Object.keys(rootReads).length > 0 ? { rootReads } : {}),
307
363
  ...(Object.keys(stackExports).length > 0 ? { stackExports } : {}),
308
364
  };
309
365
  }
@@ -370,10 +426,14 @@ export interface DeclaredEntity {
370
426
  * issued against. The harness collects it into the result's `queried` map (and
371
427
  * onto the unobserved entry), so even an absent verdict — which records
372
428
  * nothing else — says where the provider was asked.
429
+ *
430
+ * An absent verdict may also carry `annotations` (#4372): attributes the read
431
+ * learned about the entity without finding it, collected into the result's
432
+ * `annotations` map. They never make it present.
373
433
  */
374
434
  export type EntityObservation =
375
435
  | { present: ResourceMetadata; queried?: string }
376
- | { absent: true; queried?: string }
436
+ | { absent: true; queried?: string; annotations?: Record<string, unknown> }
377
437
  | { unobserved: { reason: UnobservedReason; detail?: string }; queried?: string };
378
438
 
379
439
  /** What a lexicon supplies to drive the harness. `Client` is its transport handle. */
@@ -463,6 +523,7 @@ export async function observeEntities<Client>(
463
523
  const resources: Record<string, ResourceMetadata> = {};
464
524
  const unobserved: Record<string, UnobservedEntity> = {};
465
525
  const queried: Record<string, string> = {};
526
+ const annotations: Record<string, Record<string, unknown>> = {};
466
527
  const run = adapter.concurrently ?? ((items, fn) => boundedConcurrently(items, fn));
467
528
 
468
529
  await run(declared, async (entity) => {
@@ -486,11 +547,14 @@ export async function observeEntities<Client>(
486
547
  ...result.unobserved,
487
548
  ...(result.queried ? { queried: result.queried } : {}),
488
549
  };
550
+ } else if (result.annotations && Object.keys(result.annotations).length > 0) {
551
+ // `absent`: the verdict records nothing — in neither map is how the
552
+ // contract spells absence — but its address, when the adapter supplied
553
+ // one, lands in `queried` (#1620) so the absence can explain itself, and
554
+ // what the read learned about it lands in `annotations` (#4372).
555
+ annotations[entity.name] = result.annotations;
489
556
  }
490
- // `absent`: the verdict records nothing — in neither map is how the
491
- // contract spells absence — but its address, when the adapter supplied
492
- // one, lands in `queried` (#1620) so the absence can explain itself.
493
557
  });
494
558
 
495
- return observation(resources, unobserved, queried);
559
+ return observation(resources, unobserved, queried, undefined, undefined, undefined, annotations);
496
560
  }