@intentius/chant 0.58.0 → 0.59.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 (146) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/cli/commands/audit.d.ts.map +1 -1
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/lint.d.ts +13 -0
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/operator.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts +0 -16
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/plugins.d.ts +22 -0
  15. package/dist/cli/plugins.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/cli-support.d.ts +4 -1
  19. package/dist/components/cli-support.d.ts.map +1 -1
  20. package/dist/components/component.d.ts +19 -4
  21. package/dist/components/component.d.ts.map +1 -1
  22. package/dist/components/driver.d.ts +8 -2
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/verbs/run-agent.d.ts +1 -7
  25. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  26. package/dist/detectLexicon.d.ts +13 -0
  27. package/dist/detectLexicon.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +170 -3
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  31. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  32. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +2 -2
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/reconcile.d.ts +62 -2
  36. package/dist/op/activities/reconcile.d.ts.map +1 -1
  37. package/dist/op/builders.d.ts +2 -2
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/change-signal.d.ts +91 -0
  40. package/dist/op/change-signal.d.ts.map +1 -0
  41. package/dist/op/composites/apply-op.d.ts +7 -2
  42. package/dist/op/composites/apply-op.d.ts.map +1 -1
  43. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  44. package/dist/op/gate-name.d.ts +40 -0
  45. package/dist/op/gate-name.d.ts.map +1 -0
  46. package/dist/op/gate-summary.d.ts +43 -0
  47. package/dist/op/gate-summary.d.ts.map +1 -0
  48. package/dist/op/index.d.ts +6 -2
  49. package/dist/op/index.d.ts.map +1 -1
  50. package/dist/op/local-executor.d.ts.map +1 -1
  51. package/dist/op/op-ir.d.ts +2 -1
  52. package/dist/op/op-ir.d.ts.map +1 -1
  53. package/dist/op/operator.d.ts +63 -0
  54. package/dist/op/operator.d.ts.map +1 -1
  55. package/dist/op/types.d.ts +18 -3
  56. package/dist/op/types.d.ts.map +1 -1
  57. package/dist/terraform/bridge.d.ts +26 -7
  58. package/dist/terraform/bridge.d.ts.map +1 -1
  59. package/dist/terraform/carve-provider.d.ts +28 -2
  60. package/dist/terraform/carve-provider.d.ts.map +1 -1
  61. package/dist/terraform/data-source-shape.d.ts +77 -0
  62. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  63. package/dist/terraform/graph.d.ts.map +1 -1
  64. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  65. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  66. package/dist/terraform/tier-map.d.ts +16 -4
  67. package/dist/terraform/tier-map.d.ts.map +1 -1
  68. package/dist/terraform/types.d.ts +8 -0
  69. package/dist/terraform/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/audit/core.ts +25 -3
  72. package/src/audit/discover.ts +122 -14
  73. package/src/cli/commands/audit.test.ts +5 -3
  74. package/src/cli/commands/audit.ts +5 -1
  75. package/src/cli/commands/build.test.ts +39 -1
  76. package/src/cli/commands/build.ts +14 -0
  77. package/src/cli/commands/carve-bridge.test.ts +237 -14
  78. package/src/cli/commands/carve-bridge.ts +21 -17
  79. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  80. package/src/cli/commands/lint.test.ts +146 -1
  81. package/src/cli/commands/lint.ts +82 -7
  82. package/src/cli/handlers/operator.ts +81 -0
  83. package/src/cli/handlers/run.test.ts +132 -3
  84. package/src/cli/handlers/run.ts +87 -3
  85. package/src/cli/main.test.ts +9 -11
  86. package/src/cli/main.ts +5 -27
  87. package/src/cli/plugins.test.ts +68 -2
  88. package/src/cli/plugins.ts +39 -0
  89. package/src/cli/registry.ts +14 -0
  90. package/src/components/README.md +2 -2
  91. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  92. package/src/components/cli-support.test.ts +18 -7
  93. package/src/components/cli-support.ts +8 -3
  94. package/src/components/component-schema.test.ts +18 -2
  95. package/src/components/component.schema.json +17 -4
  96. package/src/components/component.test.ts +2 -2
  97. package/src/components/component.ts +25 -5
  98. package/src/components/config-defaults.test.ts +2 -2
  99. package/src/components/driver.test.ts +20 -1
  100. package/src/components/driver.ts +12 -5
  101. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  102. package/src/components/verbs/run-agent.test.ts +19 -0
  103. package/src/components/verbs/run-agent.ts +1 -7
  104. package/src/detectLexicon.ts +18 -1
  105. package/src/discovery/fold-import.test.ts +1 -1
  106. package/src/fold/foldable-helpers.ts +1 -1
  107. package/src/graph-ops.test.ts +1 -1
  108. package/src/lexicon.ts +177 -3
  109. package/src/lifecycle/gate-ledger.ts +12 -1
  110. package/src/lint/pipeline-change-gate.test.ts +2 -2
  111. package/src/lint/rules/comp/comp.test.ts +26 -0
  112. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  113. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  114. package/src/op/activities/index.ts +2 -2
  115. package/src/op/activities/reconcile.test.ts +82 -2
  116. package/src/op/activities/reconcile.ts +169 -2
  117. package/src/op/builders.ts +3 -3
  118. package/src/op/change-signal.test.ts +117 -0
  119. package/src/op/change-signal.ts +169 -0
  120. package/src/op/composites/apply-op.ts +14 -4
  121. package/src/op/composites/composites.test.ts +17 -4
  122. package/src/op/composites/reconcile-op.ts +7 -4
  123. package/src/op/effect-step.test.ts +3 -3
  124. package/src/op/gate-name.test.ts +65 -0
  125. package/src/op/gate-name.ts +60 -0
  126. package/src/op/gate-summary.ts +84 -0
  127. package/src/op/index.ts +9 -2
  128. package/src/op/local-executor.test.ts +16 -1
  129. package/src/op/local-executor.ts +4 -3
  130. package/src/op/op-ir.test.ts +12 -1
  131. package/src/op/op-ir.ts +5 -3
  132. package/src/op/op-verb-class.test.ts +2 -2
  133. package/src/op/op.test.ts +2 -2
  134. package/src/op/operator.test.ts +368 -0
  135. package/src/op/operator.ts +141 -5
  136. package/src/op/runtimes/local.test.ts +1 -1
  137. package/src/op/types.ts +23 -3
  138. package/src/terraform/aws-resources.test.ts +13 -4
  139. package/src/terraform/bridge.test.ts +22 -9
  140. package/src/terraform/bridge.ts +89 -28
  141. package/src/terraform/carve-provider.ts +38 -2
  142. package/src/terraform/data-source-shape.ts +95 -0
  143. package/src/terraform/graph.ts +35 -7
  144. package/src/terraform/providers/kubernetes.ts +48 -2
  145. package/src/terraform/tier-map.ts +21 -6
  146. package/src/terraform/types.ts +8 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * How a carved Terraform type is read back as a `data` source (#2034).
3
+ *
4
+ * `carve bridge` replaces a carved resource with a data source the survivors
5
+ * read instead. Until now a carve provider said only which attribute carries
6
+ * the physical name (`identityAttrs`), and the bridge assumed the data source
7
+ * was the same type as the resource, with that one attribute in its body:
8
+ *
9
+ * data "aws_s3_bucket" "assets" { bucket = "myapp-assets-prod" }
10
+ *
11
+ * That assumption holds for AWS and google, and breaks for Kubernetes twice
12
+ * over. `kubernetes_manifest` names itself by `manifest.metadata.name`, a path
13
+ * into a nested value that a flat `attr = value` body cannot express; and the
14
+ * kubernetes provider ships no `kubernetes_manifest` data source at all
15
+ * (verified against hashicorp/kubernetes v3.2.1: 27 data sources, none a
16
+ * manifest). Its generic read is `data "kubernetes_resource"`, with a
17
+ * different type, different arguments, a nested `metadata` block, and its own
18
+ * attribute path for survivors to read through.
19
+ *
20
+ * So a provider contributes a *shape*: the data-source type, where each
21
+ * argument comes from in the carved body, and how a survivor's attribute path
22
+ * translates. An identity attribute is one instance of that shape
23
+ * ({@link identityAttrShape}), so nothing that bridges today changes.
24
+ */
25
+ /** One argument of the data-source body, and where its value comes from. */
26
+ export interface ShapeField {
27
+ /** Argument name in the emitted data source, e.g. `api_version`. */
28
+ name: string;
29
+ /**
30
+ * Dotted path into the carved resource's HCL body the literal is read from,
31
+ * e.g. `manifest.apiVersion`. Nested blocks are walked by segment.
32
+ */
33
+ from: string;
34
+ /**
35
+ * A shape that cannot resolve a required field renders the TODO body rather
36
+ * than a data source missing an argument Terraform needs.
37
+ */
38
+ required?: boolean;
39
+ }
40
+ /** A nested block in the data-source body, e.g. `metadata { name = "x" }`. */
41
+ export interface ShapeBlock {
42
+ name: string;
43
+ fields: readonly ShapeField[];
44
+ }
45
+ export interface DataSourceShape {
46
+ /** The `data` type standing in for the carved resource, e.g. `kubernetes_resource`. */
47
+ type: string;
48
+ /** Flat `name = value` arguments of the body. */
49
+ args?: readonly ShapeField[];
50
+ /** Nested `name { ... }` blocks of the body. */
51
+ blocks?: readonly ShapeBlock[];
52
+ /**
53
+ * The carved resource's top-level attribute → the data source's, for the
54
+ * survivor rewrite. `kubernetes_manifest` exposes both `manifest` (what the
55
+ * config declared) and `object` (the API server's read-back), and
56
+ * `kubernetes_resource` exposes only `object`, so both map to `object`. An
57
+ * attribute with no entry passes through unchanged, which is what every
58
+ * same-type identity-attribute shape wants.
59
+ */
60
+ readAttrs?: Readonly<Record<string, string>>;
61
+ }
62
+ /**
63
+ * The shape a plain identity attribute implies: the same type as the resource,
64
+ * one required argument, no path translation. Returns undefined for a dotted
65
+ * attribute, which is a path into nested values and needs a declared shape.
66
+ */
67
+ export declare function identityAttrShape(tfType: string, attr: string): DataSourceShape | undefined;
68
+ /** Every source path the shape reads, for the graph to resolve out of the carved block. */
69
+ export declare function shapeSourcePaths(shape: DataSourceShape): string[];
70
+ /**
71
+ * Translate a survivor's attribute path onto the data source. `path` is the
72
+ * text after the address, leading dot included (`.manifest.data.log_level`) or
73
+ * empty. Only the first segment is mapped: everything under it is the object's
74
+ * own structure, which the data source reproduces.
75
+ */
76
+ export declare function rewriteReadPath(shape: DataSourceShape | undefined, path: string): string;
77
+ //# sourceMappingURL=data-source-shape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-source-shape.d.ts","sourceRoot":"","sources":["../../src/terraform/data-source-shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,4EAA4E;AAC5E,MAAM,WAAW,UAAU;IACzB,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,8EAA8E;AAC9E,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,IAAI,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IAC7B,gDAAgD;IAChD,MAAM,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;IAC/B;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC9C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAG3F;AAED,2FAA2F;AAC3F,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,EAAE,CAIjE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAMxF"}
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../src/terraform/graph.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,OAAO,EAAU,MAAM,SAAS,CAAC;AAErE;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;AAKpE,UAAU,MAAM;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AA4BD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAe/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAe/D;AA4DD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,cAAc,GAAG,OAAO,CAoFhF;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAEtE;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../src/terraform/graph.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,OAAO,EAAU,MAAM,SAAS,CAAC;AAErE;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;AAKpE,UAAU,MAAM;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AA4BD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAe/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAe/D;AAsFD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,cAAc,GAAG,OAAO,CAqFhF;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAEtE;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvE"}
@@ -18,6 +18,11 @@
18
18
  * can only name the family, and `adopt` reads the body to learn the rest —
19
19
  * which is also why one rule here covers every CRD.
20
20
  *
21
+ * It has no `kubernetes_manifest` data source either, so it does not read back
22
+ * as its own type. `MANIFEST_READ_BACK` below is the shape `carve bridge` uses
23
+ * instead: `data "kubernetes_resource"`, built from the manifest's own
24
+ * apiVersion/kind/metadata (#2034).
25
+ *
21
26
  * It has no live adoption path. `carve emit --env` filters a live export by a
22
27
  * native type, and there is no type to filter by until the body is read; state
23
28
  * is where the body is. `liveSelectorType` therefore returns undefined and
@@ -1 +1 @@
1
- {"version":3,"file":"kubernetes.d.ts","sourceRoot":"","sources":["../../../src/terraform/providers/kubernetes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAiB,aAAa,EAA2B,MAAM,mBAAmB,CAAC;AAuL/F,eAAO,MAAM,uBAAuB,EAAE,aAarC,CAAC"}
1
+ {"version":3,"file":"kubernetes.d.ts","sourceRoot":"","sources":["../../../src/terraform/providers/kubernetes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAiB,aAAa,EAA2B,MAAM,mBAAmB,CAAC;AA+N/F,eAAO,MAAM,uBAAuB,EAAE,aAcrC,CAAC"}
@@ -17,7 +17,9 @@
17
17
  * provider registered after module load has to be visible here.
18
18
  */
19
19
  import { type TierInfo } from "./carve-provider.js";
20
+ import type { DataSourceShape } from "./data-source-shape.js";
20
21
  export { carveEmitTypes, type TierInfo } from "./carve-provider.js";
22
+ export type { DataSourceShape } from "./data-source-shape.js";
21
23
  /** TF resource type → native tier, over every registered provider. Absent = unsupported (score 0). */
22
24
  export declare function tierMap(): Readonly<Record<string, TierInfo>>;
23
25
  export declare function resolveTier(tfType: string): TierInfo | null;
@@ -42,10 +44,20 @@ export declare function foldParentOf(tfType: string): string | undefined;
42
44
  */
43
45
  export declare function canCarveEmit(tfType: string): boolean;
44
46
  /**
45
- * Can `chant carve bridge` render a Terraform `data` source for this type? A
46
- * dotted identity attribute is a path into nested blocks, and a data source
47
- * body is flat `attr = value` — `manifest.metadata.name = "x"` is not valid
48
- * HCL. An absent entry is fine: the bridge writes a TODO comment instead.
47
+ * How `chant carve bridge` reads this type back as a `data` source: the
48
+ * data-source type, where each argument comes from in the carved body, and how
49
+ * a survivor's attribute path translates (#2034). Undefined when the type
50
+ * cannot be read back at all.
51
+ */
52
+ export declare function dataSourceShapeOf(tfType: string): DataSourceShape | undefined;
53
+ /**
54
+ * Can `chant carve bridge` render a Terraform `data` source for this type?
55
+ * True whenever its provider contributes a shape — declared, or implied by a
56
+ * plain identity attribute. False for a dotted identity attribute with no
57
+ * declared shape: that is a path into nested values, and a flat `attr = value`
58
+ * body cannot express it (`manifest.metadata.name = "x"` is not valid HCL). A
59
+ * type with no identity attribute at all still bridges; the bridge writes a
60
+ * TODO comment for the body.
49
61
  */
50
62
  export declare function canBridge(tfType: string): boolean;
51
63
  //# sourceMappingURL=tier-map.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tier-map.d.ts","sourceRoot":"","sources":["../../src/terraform/tier-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAKL,KAAK,QAAQ,EACd,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjE,sGAAsG;AACtG,wBAAgB,OAAO,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAE5D;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAE3D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjE;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE/D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGjD"}
1
+ {"version":3,"file":"tier-map.d.ts","sourceRoot":"","sources":["../../src/terraform/tier-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAML,KAAK,QAAQ,EACd,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACjE,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D,sGAAsG;AACtG,wBAAgB,OAAO,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAE5D;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAE3D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjE;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE/D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAE7E;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEjD"}
@@ -33,6 +33,14 @@ export interface TfNode {
33
33
  * emit. Undefined when the identity attribute is absent or interpolated.
34
34
  */
35
35
  identity?: string;
36
+ /**
37
+ * The literals the type's data-source shape reads out of the block (#2034),
38
+ * keyed by the shape field's source path (`manifest.metadata.name`). This is
39
+ * what `carve bridge` renders the `data` body from — a superset of
40
+ * `identity`, since a shape can need more than one value (`api_version`,
41
+ * `kind` and `metadata.name` for a `kubernetes_manifest`).
42
+ */
43
+ dataSourceValues?: Record<string, string>;
36
44
  }
37
45
  /**
38
46
  * A directed dependency edge: `from` references `to`.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/terraform/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,MAAM;IACrB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC5B,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;;;OAKG;IACH,GAAG,EAAE,MAAM,EAAE,CAAC;IACd;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IACjD,8FAA8F;IAC9F,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACnC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/terraform/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,MAAM;IACrB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC5B,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,UAAU,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;;;OAKG;IACH,GAAG,EAAE,MAAM,EAAE,CAAC;IACd;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IACjD,8FAA8F;IAC9F,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IACnC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/audit/core.ts CHANGED
@@ -25,7 +25,7 @@ import type { Severity } from "../lint/rule";
25
25
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../lint/post-synth";
26
26
  import { applyInlineSuppressions, type SuppressionMetaFinding } from "../lint/suppressions";
27
27
  import type { SerializerResult } from "../serializer";
28
- import type { LexiconPlugin } from "../lexicon";
28
+ import type { AuditEntitiesInput, LexiconPlugin } from "../lexicon";
29
29
  import type { Declarable } from "../declarable";
30
30
 
31
31
  /**
@@ -61,6 +61,16 @@ export interface AuditInput {
61
61
  * — checks that read `output.files` (helm, docker) see the whole bundle.
62
62
  */
63
63
  files?: Record<string, string>;
64
+ /**
65
+ * Absolute directory this input's files were read from, set by discovery on
66
+ * a local walk for the inputs whose `path` names a directory (#2217). It
67
+ * reaches a lexicon's `auditEntities` as `AuditEntitiesInput.dir`, which is
68
+ * what lets terraform follow a root module's local `module` calls. Undefined
69
+ * for a remote tree fetch and for a single-file input.
70
+ */
71
+ dir?: string;
72
+ /** Absolute path of the audited root, when discovery walked one (#2217). The boundary `dir` may read within. */
73
+ baseDir?: string;
64
74
  }
65
75
 
66
76
  /** A finding produced by a post-synth check against an audited file. */
@@ -99,8 +109,16 @@ export type ChecksProvider = (lexicon: AuditLexicon) => Promise<PostSynthCheck[]
99
109
  * `ctx.entities` holds during a build, so entity-reading checks run unchanged.
100
110
  * May return a `Promise` for a lexicon whose parser is inherently async (e.g.
101
111
  * terraform's HCL parser); `auditLexicon`'s `entitiesFor` awaits it.
112
+ *
113
+ * The second argument carries the input's discovery facts (its path, and the
114
+ * directory it was read from on a local walk), so a lexicon whose unit of
115
+ * parsing is a directory can name its scope after that directory and read the
116
+ * directories it references (#2217).
102
117
  */
103
- export type EntitiesParser = (content: string) => Map<string, Declarable> | Promise<Map<string, Declarable>>;
118
+ export type EntitiesParser = (
119
+ content: string,
120
+ input?: AuditEntitiesInput,
121
+ ) => Map<string, Declarable> | Promise<Map<string, Declarable>>;
104
122
 
105
123
  /**
106
124
  * Resolve the entities parser for a lexicon (its plugin's `auditEntities`,
@@ -322,7 +340,11 @@ async function auditLexicon(
322
340
  const entitiesFor = async (file: AuditInput): Promise<Map<string, Declarable>> => {
323
341
  if (!parseEntities) return new Map();
324
342
  try {
325
- return await parseEntities(file.content);
343
+ return await parseEntities(file.content, {
344
+ path: file.path,
345
+ ...(file.dir !== undefined ? { dir: file.dir } : {}),
346
+ ...(file.baseDir !== undefined ? { baseDir: file.baseDir } : {}),
347
+ });
326
348
  } catch {
327
349
  // The audit contract is "runs against any repo" — unparseable content
328
350
  // contributes no entities, never a crash.
@@ -23,9 +23,10 @@
23
23
  * read `output.files`, so the whole chart is one AuditInput.
24
24
  * - Terraform is also a directory BUNDLE, keyed by that directory's own
25
25
  * `.tf` filenames, not recursively: a nested directory with its own
26
- * `.tf` files (e.g. `modules/foo`) is a separate root module and gets its
27
- * own AuditInput. `.tf` isn't YAML/JSON either, so `detectTemplate` can't
28
- * see it.
26
+ * `.tf` files (e.g. `modules/foo`) is its own AuditInput, UNLESS another
27
+ * directory's `.tf` calls it as a local module, in which case it is a
28
+ * child module of that root and is parsed as part of it (#2217). `.tf`
29
+ * isn't YAML/JSON either, so `detectTemplate` can't see it.
29
30
  * - k8s `detectTemplate` matches any `apiVersion`+`kind`, including GCP Config
30
31
  * Connector (`cnrm.cloud.google.com`) resources and fountain
31
32
  * (`fountain.dev/v1`) manifests, so gcp and fountain must be tried first.
@@ -34,7 +35,7 @@
34
35
  */
35
36
 
36
37
  import { readdirSync, readFileSync, statSync } from "fs";
37
- import { basename, join, relative } from "path";
38
+ import { basename, join, relative, resolve } from "path";
38
39
  import { parseYAML } from "../yaml";
39
40
  import type { LexiconPlugin } from "../lexicon";
40
41
  import type { AuditInput, AuditLexicon } from "./core";
@@ -391,17 +392,103 @@ function classifyHelm(files: RepoFile[], plugin: DetectPlugin | undefined): { in
391
392
  return { inputs, prefixes };
392
393
  }
393
394
 
395
+ /**
396
+ * A `source` attribute naming a local path, the only module source Terraform
397
+ * reads from the repository itself. A registry or git source ("hashicorp/aws",
398
+ * "git::https://...") never starts with `./` or `../`, and `source` inside
399
+ * `required_providers` is a registry address, so the prefix is what tells the
400
+ * two apart without an HCL parser here. `classifyTerraform` uses this to tell
401
+ * a root module from a directory that is somebody's child module; the lexicon
402
+ * plugin parses the calls properly once it has the root.
403
+ */
404
+ const LOCAL_MODULE_SOURCE = /(?:^|[\s{,])source\s*=\s*"(\.{1,2}\/[^"]*)"/g;
405
+
406
+ /**
407
+ * Resolve `rel` against the repo-relative directory `dir`. Returns undefined
408
+ * when the path climbs above the audited root, which is a directory this walk
409
+ * never read and so can never be one of its inputs.
410
+ */
411
+ function resolveRepoDir(dir: string, rel: string): string | undefined {
412
+ const parts = dir === "" ? [] : dir.split("/");
413
+ for (const segment of rel.split("/")) {
414
+ if (segment === "" || segment === ".") continue;
415
+ if (segment === "..") {
416
+ if (parts.length === 0) return undefined;
417
+ parts.pop();
418
+ continue;
419
+ }
420
+ parts.push(segment);
421
+ }
422
+ return parts.join("/");
423
+ }
424
+
425
+ /**
426
+ * The directories that are somebody's local child module: a `.tf` file in
427
+ * another directory of this walk calls them with a relative `source`.
428
+ *
429
+ * They are dropped as audit inputs of their own, because the root that calls
430
+ * them parses them as child scopes (the terraform plugin's `auditEntities`
431
+ * descends from `AuditInput.dir`, #2217). Auditing them twice would report
432
+ * every finding in them twice, once per scope, and auditing them as roots is
433
+ * what made `chant audit` disagree with `chant build` about which rules apply
434
+ * to a child module at all.
435
+ *
436
+ * A directory a cycle reaches but no root does is kept: `a` calling `b`
437
+ * calling `a` is a configuration Terraform refuses, and dropping both would
438
+ * leave the files audited by nothing.
439
+ */
440
+ function calledAsLocalModule(byDir: Map<string, Record<string, string>>): Set<string> {
441
+ const callees = new Map<string, Set<string>>();
442
+ const called = new Set<string>();
443
+ for (const [dir, bundle] of byDir) {
444
+ const targets = new Set<string>();
445
+ for (const source of Object.values(bundle)) {
446
+ for (const match of source.matchAll(LOCAL_MODULE_SOURCE)) {
447
+ const target = resolveRepoDir(dir, match[1]);
448
+ if (target === undefined || target === dir || !byDir.has(target)) continue;
449
+ targets.add(target);
450
+ called.add(target);
451
+ }
452
+ }
453
+ callees.set(dir, targets);
454
+ }
455
+
456
+ const queue = [...byDir.keys()].filter((dir) => !called.has(dir));
457
+ const reached = new Set<string>(queue);
458
+ while (queue.length > 0) {
459
+ for (const target of callees.get(queue.shift()!) ?? []) {
460
+ if (reached.has(target)) continue;
461
+ reached.add(target);
462
+ queue.push(target);
463
+ }
464
+ }
465
+ return new Set([...called].filter((dir) => reached.has(dir)));
466
+ }
467
+
394
468
  /**
395
469
  * Collect Terraform root modules as directory bundles from an in-memory file
396
470
  * set, modeled on `classifyHelm`. Unlike a Helm chart (its whole subtree is
397
471
  * one bundle), a Terraform root module references only its own sibling
398
472
  * files, so grouping is by each `.tf` file's immediate directory, not
399
- * recursive: a nested directory with its own `.tf` files (`modules/foo`) is
400
- * a separate root module and gets its own AuditInput. Returns inputs plus
401
- * the exact `.tf` paths claimed, so the loose-file pass in `classifyFiles`
402
- * can skip them.
473
+ * recursive.
474
+ *
475
+ * A directory another one calls as a local module is not a root module and
476
+ * gets no input of its own (#2217): the root that calls it parses it as a
477
+ * child scope, which is what Terraform does and what `chant build` already
478
+ * did. That descent reads the child's directory from disk, so it only happens
479
+ * when `baseDir` says a local walk found these files; an in-memory file set
480
+ * with no directory behind it keeps every `.tf` directory as its own input,
481
+ * as before.
482
+ *
483
+ * Returns inputs plus the exact `.tf` paths claimed, so the loose-file pass in
484
+ * `classifyFiles` can skip them — a child module's files are claimed too, by
485
+ * the root that will parse them.
403
486
  */
404
- function classifyTerraform(files: RepoFile[], plugin: DetectPlugin | undefined): { inputs: AuditInput[]; claimed: Set<string> } {
487
+ function classifyTerraform(
488
+ files: RepoFile[],
489
+ plugin: DetectPlugin | undefined,
490
+ baseDir?: string,
491
+ ): { inputs: AuditInput[]; claimed: Set<string> } {
405
492
  const inputs: AuditInput[] = [];
406
493
  const claimed = new Set<string>();
407
494
  if (!plugin) return { inputs, claimed };
@@ -416,20 +503,40 @@ function classifyTerraform(files: RepoFile[], plugin: DetectPlugin | undefined):
416
503
  byDir.set(dir, bundle);
417
504
  claimed.add(f.path);
418
505
  }
506
+ const childModules = baseDir === undefined ? new Set<string>() : calledAsLocalModule(byDir);
419
507
  for (const [dir, bundle] of byDir) {
508
+ if (childModules.has(dir)) continue;
420
509
  // `content` is the whole root module, every `.tf` in filename order with a
421
510
  // `# file:` line comment (legal HCL) marking each boundary, so a lexicon's
422
511
  // `auditEntities(content)` sees the module Terraform itself would load,
423
- // not one arbitrary file of it. `files` keeps the per-file split.
512
+ // not one arbitrary file of it. `files` keeps the per-file split, and
513
+ // `dir` is where the local modules it calls are read from.
424
514
  const content = Object.keys(bundle)
425
515
  .sort()
426
516
  .map((name) => `# file: ${name}\n${bundle[name]}`)
427
517
  .join("\n");
428
- inputs.push({ path: dir === "" ? "." : dir, content, lexicon: "terraform", files: bundle });
518
+ inputs.push({
519
+ path: dir === "" ? "." : dir,
520
+ content,
521
+ lexicon: "terraform",
522
+ files: bundle,
523
+ ...(baseDir === undefined ? {} : { dir: resolve(baseDir, dir), baseDir }),
524
+ });
429
525
  }
430
526
  return { inputs, claimed };
431
527
  }
432
528
 
529
+ /** Where a set of classified files came from, when they came from a local walk (#2217). */
530
+ export interface ClassifyOptions {
531
+ /**
532
+ * Absolute path of the directory that was walked. Travels onto the inputs
533
+ * whose `path` names a directory, so a lexicon's `auditEntities` can read
534
+ * the files that directory references (terraform's local module calls).
535
+ * Omit for an in-memory or fetched file set: there is no directory to read.
536
+ */
537
+ baseDir?: string;
538
+ }
539
+
433
540
  /**
434
541
  * Classify an in-memory set of repo files into per-lexicon audit inputs. Pure
435
542
  * (no fs, no network) so the local walk and the remote tree-fetch share exactly
@@ -439,15 +546,16 @@ function classifyTerraform(files: RepoFile[], plugin: DetectPlugin | undefined):
439
546
  * (delegating to each plugin's `detectTemplate`). Lexicons whose plugin isn't
440
547
  * provided are skipped.
441
548
  */
442
- export function classifyFiles(files: RepoFile[], plugins: DetectPlugin[]): AuditInput[] {
549
+ export function classifyFiles(files: RepoFile[], plugins: DetectPlugin[], opts: ClassifyOptions = {}): AuditInput[] {
443
550
  const byName = new Map(plugins.map((p) => [p.name, p]));
551
+ const baseDir = opts.baseDir === undefined ? undefined : resolve(opts.baseDir);
444
552
 
445
553
  // Helm claims whole chart directories first; chart-internal files are excluded
446
554
  // from the loose-file pass so templates aren't double-audited.
447
555
  const helm = classifyHelm(files, byName.get("helm"));
448
556
  const underChart = (p: string): boolean => helm.prefixes.some((pre) => (pre === "" ? true : p.startsWith(pre)));
449
557
 
450
- const terraform = classifyTerraform(files, byName.get("terraform"));
558
+ const terraform = classifyTerraform(files, byName.get("terraform"), baseDir);
451
559
 
452
560
  const inputs: AuditInput[] = [...helm.inputs, ...terraform.inputs];
453
561
  for (const { path, content } of files) {
@@ -483,7 +591,7 @@ export function classifyFiles(files: RepoFile[], plugins: DetectPlugin[]): Audit
483
591
  * lexicon whose plugin isn't provided, so a caller can scope discovery.
484
592
  */
485
593
  export function discoverByDetection(root: string, plugins: DetectPlugin[]): AuditInput[] {
486
- return classifyFiles(collectCandidates(root), plugins);
594
+ return classifyFiles(collectCandidates(root), plugins, { baseDir: root });
487
595
  }
488
596
 
489
597
  /** The walk half of `discoverByDetection`: candidate files read into memory, paths relative to the root. */
@@ -470,6 +470,8 @@ describe("auditCommand", () => {
470
470
  expect(valid).toBe(true);
471
471
  });
472
472
 
473
+ // The scope is the discovered root module's own directory (`infra`), which
474
+ // is what the terraform plugin names an audited scope after since #2217.
473
475
  describe("TF001's missing-resource shape renders in all three reporters (chant #2113)", () => {
474
476
  const dir = join(tmpdir(), `chant-audit-tf-missing-${process.pid}`);
475
477
 
@@ -484,7 +486,7 @@ describe("auditCommand", () => {
484
486
  test("stylish names the missing kind and scope instead of falling back to entity", async () => {
485
487
  setup();
486
488
  const result = await auditCommand({ path: dir, format: "stylish" });
487
- expect(result.output).toContain("(missing backend in audit-root)");
489
+ expect(result.output).toContain("(missing backend in infra)");
488
490
  teardown();
489
491
  });
490
492
 
@@ -493,7 +495,7 @@ describe("auditCommand", () => {
493
495
  const result = await auditCommand({ path: dir, format: "json" });
494
496
  const doc = JSON.parse(result.output) as { findings: Array<{ checkId: string; missing?: { kind: string; scope: string } }> };
495
497
  const tf001 = doc.findings.find((f) => f.checkId === "TF001");
496
- expect(tf001?.missing).toEqual({ kind: "backend", scope: "audit-root" });
498
+ expect(tf001?.missing).toEqual({ kind: "backend", scope: "infra" });
497
499
  teardown();
498
500
  });
499
501
 
@@ -504,7 +506,7 @@ describe("auditCommand", () => {
504
506
  runs: Array<{ results: Array<{ ruleId: string; properties?: { missing?: { kind: string; scope: string } } }> }>;
505
507
  };
506
508
  const tf001 = doc.runs[0].results.find((r) => r.ruleId === "TF001");
507
- expect(tf001?.properties?.missing).toEqual({ kind: "backend", scope: "audit-root" });
509
+ expect(tf001?.properties?.missing).toEqual({ kind: "backend", scope: "infra" });
508
510
 
509
511
  const ajv = new Ajv({ strict: false, allErrors: true });
510
512
  const validate = ajv.compile(loadSarifSchema() as object);
@@ -408,7 +408,11 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
408
408
  // shape alone can't disambiguate them.
409
409
  candidates = collectCandidates(options.path);
410
410
  }
411
- const inputs = classifyFiles(candidates, plugins);
411
+ // A local target hands its directory to the classifier, so an input whose
412
+ // path names a directory carries the directory itself (#2217): terraform's
413
+ // parse-to-graph follows a root module's local `module` calls from there. A
414
+ // URL target has no local tree, and never fetches `.tf` in the first place.
415
+ const inputs = classifyFiles(candidates, plugins, isUrl ? {} : { baseDir: options.path });
412
416
  const unclaimed = unclaimedFiles(candidates, inputs, plugins);
413
417
  const scanned = inputs.map((i) => i.path);
414
418
 
@@ -1445,7 +1445,7 @@ export const pruneStaging = {
1445
1445
  name: "prune-staging",
1446
1446
  overview: "test",
1447
1447
  phases: [
1448
- { name: "Approve", steps: [{ kind: "gate", signalName: "approve-x" }] },
1448
+ { name: "Approve", steps: [{ kind: "gate", gate: "approve-x" }] },
1449
1449
  { name: "Apply", steps: [
1450
1450
  { kind: "activity", fn: "nativeApply", args: { target: "kubectl", env: "staging", output: "dist", deleteMode: "gated" } },
1451
1451
  ] },
@@ -1502,6 +1502,44 @@ export const converge = {
1502
1502
  ),
1503
1503
  ).toBe(true);
1504
1504
  });
1505
+
1506
+ // #2202: a gate step spelling its name `signalName` still builds, and the
1507
+ // build says so once, naming the Ops involved rather than each gate step.
1508
+ test("warns once, naming the Ops, when a gate step still uses the deprecated `signalName` key", async () => {
1509
+ const opFile = (name: string): string => `
1510
+ export const ${name.replace(/-/g, "")} = {
1511
+ [Symbol.for("chant.declarable")]: true,
1512
+ entityType: "Chant::Op",
1513
+ lexicon: "chant",
1514
+ kind: "resource",
1515
+ props: {
1516
+ name: "${name}",
1517
+ overview: "test",
1518
+ phases: [{ name: "Approve", steps: [{ kind: "gate", signalName: "approve-x" }] }],
1519
+ },
1520
+ };
1521
+ `;
1522
+ await writeFile(join(testDir, "old-a.op.ts"), opFile("old-a"));
1523
+ await writeFile(join(testDir, "old-b.op.ts"), opFile("old-b"));
1524
+ await writeFile(
1525
+ join(testDir, "new-c.op.ts"),
1526
+ opFile("new-c").replace("signalName", "gate"),
1527
+ );
1528
+
1529
+ const result = await buildCommand({
1530
+ path: testDir,
1531
+ format: "json",
1532
+ serializers: [mockSerializer],
1533
+ plugins: [],
1534
+ });
1535
+
1536
+ const deprecation = result.warnings.filter((w) => w.includes("signalName"));
1537
+ expect(deprecation).toHaveLength(1);
1538
+ expect(deprecation[0]).toContain("old-a");
1539
+ expect(deprecation[0]).toContain("old-b");
1540
+ expect(deprecation[0]).not.toContain("new-c");
1541
+ expect(deprecation[0]).toContain("0.60.0");
1542
+ });
1505
1543
  });
1506
1544
 
1507
1545
  // ── #284 bug 1: -o extension drives format when --format is absent ────────
@@ -18,6 +18,7 @@ import { runPostSynthChecks, type PostSynthDiagnostic } from "../../lint/post-sy
18
18
  import { loadActivityContracts } from "../../op/activity-contract-registry";
19
19
  import { isOpEntity } from "../../op/resource";
20
20
  import { serializeOpIR } from "../../op/op-ir";
21
+ import { opUsesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "../../op/gate-name";
21
22
  import type { OpConfig } from "../../op/types";
22
23
  import { coreReceiptChecks } from "../../lint/receipt-checks";
23
24
  import { coreOutputChecks } from "../../lint/output-checks";
@@ -639,6 +640,19 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
639
640
  // not a resource manifest, so an Ops-only project legitimately produces no
640
641
  // serializer output and must not be reported as a broken import.
641
642
  const declaredOps = [...result.entities.values()].filter(isOpEntity);
643
+
644
+ // The gate step's name key was renamed from `signalName` to `gate` in #2202.
645
+ // The old key is still read; a build that finds an Op still using it says so
646
+ // once, naming every Op involved, rather than once per gate step.
647
+ const opsOnOldGateKey = declaredOps
648
+ .map((entity) => (entity as unknown as { props?: OpConfig }).props)
649
+ .filter((config): config is OpConfig => Boolean(config?.name) && opUsesDeprecatedGateKey(config as OpConfig))
650
+ .map((config) => config.name);
651
+ if (opsOnOldGateKey.length > 0) {
652
+ warnings.push(
653
+ formatWarning({ message: `${DEPRECATED_GATE_KEY_WARNING} Ops: ${opsOnOldGateKey.join(", ")}.` }),
654
+ );
655
+ }
642
656
  if (
643
657
  result.sourceFileCount > 0 &&
644
658
  result.outputs.size === 0 &&