@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
@@ -54,7 +54,7 @@ describe("mergeProjectOps (#1675)", () => {
54
54
  expect(node?.attrs.name).toBe("deploy");
55
55
  expect(node?.attrs.depends).toEqual(["inner"]);
56
56
  expect(node?.attrs.phases).toEqual([
57
- { name: "Apply", steps: [{ kind: "activity", fn: "build" }, { kind: "gate", signalName: "approve" }] },
57
+ { name: "Apply", steps: [{ kind: "activity", fn: "build" }, { kind: "gate", gate: "approve" }] },
58
58
  ]);
59
59
  // The sourceDir op discovery already loaded is untouched, not duplicated.
60
60
  expect(ir.nodes.filter((n) => n.kind === "Chant::Op")).toHaveLength(2);
package/src/lexicon.ts CHANGED
@@ -492,9 +492,14 @@ export interface ComponentPipelineResult {
492
492
  * into the Op's own activity args at build time and is never re-passed on the
493
493
  * generated CI invocation; here it decides only what token/permission wiring
494
494
  * the generated job needs to act on a finding — elevated write access for
495
- * `issue`/`pull-request`/`merge-request`, none for `report`.
495
+ * `issue`/`comment`/`pull-request`/`merge-request`, none for `report`.
496
+ *
497
+ * `comment` posts the finding on the pull request that triggered the run
498
+ * (#2231), so unlike every other mode it constrains the trigger: the github
499
+ * generator refuses it by name on anything but `pull_request`, and the gitlab
500
+ * and forgejo generators refuse it outright.
496
501
  */
497
- export type OpFindingMode = "report" | "issue" | "pull-request" | "merge-request";
502
+ export type OpFindingMode = "report" | "issue" | "comment" | "pull-request" | "merge-request";
498
503
 
499
504
  /**
500
505
  * The CI-native trigger driving a scheduled Op's generated workflow. `cron`
@@ -509,6 +514,43 @@ export type OpTrigger =
509
514
  | { kind: "pull_request"; branches?: string[] }
510
515
  | { kind: "push"; branches?: string[] };
511
516
 
517
+ /**
518
+ * One step a generated Op job runs between the checkout and the
519
+ * `beforeScript` lines (#2242). Two shapes, matching what a GitHub Actions
520
+ * step can be: a marketplace action (`uses`, with its `with:` inputs and
521
+ * `env:`), or a shell line (`run`). The `uses` shape is the reason this
522
+ * exists at all: `beforeScript` covers everything a shell line can install,
523
+ * but an action like `aws-actions/configure-aws-credentials` is not a shell
524
+ * line, and OIDC has no shell equivalent.
525
+ *
526
+ * A CI provider with no action concept degrades by name rather than
527
+ * silently: the gitlab generator refuses a `uses` entry at build time and
528
+ * emits a `run` entry as an ordinary script line.
529
+ */
530
+ export type OpSetupStep = OpSetupUsesStep | OpSetupRunStep;
531
+
532
+ /** A marketplace-action setup step (`uses:` with its inputs). */
533
+ export interface OpSetupUsesStep {
534
+ /**
535
+ * `owner/repo[/subpath]@ref`. The ref is required and must not be the
536
+ * action repository's own default branch — see the github generator's
537
+ * `assertSetupSteps`, which refuses both at build time.
538
+ */
539
+ uses: string;
540
+ /** The action's inputs, emitted as the step's `with:` mapping. */
541
+ with?: Record<string, string | number | boolean>;
542
+ /** Environment for this step alone, emitted as the step's `env:` mapping. */
543
+ env?: Record<string, string>;
544
+ }
545
+
546
+ /** A shell setup step, the same shape a `beforeScript` line emits as. */
547
+ export interface OpSetupRunStep {
548
+ /** The shell line, emitted as the step's `run:`. */
549
+ run: string;
550
+ /** Environment for this step alone, emitted as the step's `env:` mapping. */
551
+ env?: Record<string, string>;
552
+ }
553
+
512
554
  /** One scheduled Op to generate CI for — the cron-triggered counterpart to a component (generate mode). */
513
555
  export interface ScheduledOpSpec {
514
556
  /** Op name (`*.op.ts`'s `Op({ name })`) — what `chant run <name>` targets. */
@@ -535,6 +577,23 @@ export interface ScheduledOpSpec {
535
577
  opSchedule?: OpSchedule;
536
578
  /** This Op's finding-mode, for permission/token wiring only (see {@link OpFindingMode}). Default: "report" — no elevated permissions. */
537
579
  findingMode?: OpFindingMode;
580
+ /**
581
+ * Steps this Op's generated job runs between the checkout and the
582
+ * `beforeScript` lines (#2242), in the order given. Per-Op rather than a
583
+ * `ComponentPipelineOptions` knob because the setup an Op needs is a
584
+ * property of that Op: a plan job assumes a read-only role, an apply job
585
+ * assumes the one that can write.
586
+ */
587
+ setup?: OpSetupStep[];
588
+ /**
589
+ * Scopes added to the ones this Op's finding-mode already grants (#2242).
590
+ * Strictly additive: the generator refuses a scope the mode's own set
591
+ * already names, at any value, so this can neither downgrade nor restate
592
+ * least privilege, and it refuses `write-all`/`read-all` outright. The
593
+ * motivating value is `{ "id-token": "write" }`, which no finding-mode
594
+ * grants and OIDC cannot work without.
595
+ */
596
+ permissions?: Record<string, "read" | "write">;
538
597
  }
539
598
 
540
599
  /**
@@ -663,6 +722,29 @@ export type BuildRootContributor = (
663
722
  ctx: Pick<BuildRootContext, "entities">,
664
723
  ) => Promise<BuildRootContribution>;
665
724
 
725
+ /**
726
+ * Where the content handed to `auditEntities` came from (#2217).
727
+ *
728
+ * The hook's first argument is the text audit discovery classified for a
729
+ * lexicon. For most lexicons that is the whole unit of parsing. For terraform
730
+ * it is not: a root module's `module` blocks name sibling directories, and a
731
+ * rule about what a CHILD module may contain cannot be answered from one
732
+ * directory's text. So the discovery facts a lexicon may need to read further
733
+ * travel with the content.
734
+ *
735
+ * `dir` and `baseDir` are set only when discovery walked a local filesystem.
736
+ * A remote tree fetch leaves both undefined, and a lexicon that finds them
737
+ * undefined parses the content it was given and nothing else.
738
+ */
739
+ export interface AuditEntitiesInput {
740
+ /** The input's path as discovery recorded it, relative to the audited root. `"."` for the root itself. */
741
+ path: string;
742
+ /** Absolute path of the directory this input's files were read from. */
743
+ dir?: string;
744
+ /** Absolute path of the audited root: the boundary a lexicon may read within. */
745
+ baseDir?: string;
746
+ }
747
+
666
748
  export interface LexiconPlugin {
667
749
  // ── Required ──────────────────────────────────────────────
668
750
  /** Human-readable name (e.g. "aws", "gcp") */
@@ -791,8 +873,17 @@ export interface LexiconPlugin {
791
873
  * return a `Promise` for a lexicon whose parser is inherently async (e.g.
792
874
  * terraform's HCL parser, a lazy-loaded wasm module). `auditLexicon` awaits
793
875
  * it before reading `ctx.entities`.
876
+ *
877
+ * `input` says where the content came from (#2217). It is optional so a
878
+ * caller can still parse a bare string, and a lexicon that only needs the
879
+ * text ignores it. A lexicon whose unit of parsing is a directory rather
880
+ * than a file (terraform: a root module and the local modules it calls)
881
+ * reads `dir` to descend, and stays inside `baseDir` while doing it.
794
882
  */
795
- auditEntities?(content: string): Map<string, Declarable> | Promise<Map<string, Declarable>>;
883
+ auditEntities?(
884
+ content: string,
885
+ input?: AuditEntitiesInput,
886
+ ): Map<string, Declarable> | Promise<Map<string, Declarable>>;
796
887
 
797
888
  /**
798
889
  * Machine-readable spec-coverage accounting for `check-lexicon` (#1330).
@@ -1146,6 +1237,39 @@ export interface LexiconPlugin {
1146
1237
  region?: string;
1147
1238
  }): Promise<Record<string, ResourceMetadata>>;
1148
1239
 
1240
+ /**
1241
+ * Subscribe to this substrate's own change notifications (#1981), so an
1242
+ * operator tick can run when something moved instead of only when the timer
1243
+ * came round. Optional, and consumed by exactly one caller: `chant
1244
+ * operator`'s loop (`./op/operator.ts`). A lexicon that does not implement
1245
+ * it behaves exactly as it did: the loop keeps its timer and never asks.
1246
+ *
1247
+ * Three rules make the seam honest, and the types are shaped to enforce the
1248
+ * first one rather than describe it.
1249
+ *
1250
+ * - **The signal is a trigger, never a fact.** {@link
1251
+ * SubscribeChangesOptions.onChange} takes no arguments and returns
1252
+ * nothing, so there is no channel through which a watch event could reach
1253
+ * a change set, a snapshot row or a diff line. The tick that follows an
1254
+ * early wake re-observes and re-derives from scratch, exactly as a
1255
+ * timer-driven tick does; waking early changes *when* a tick runs and
1256
+ * nothing about what it concludes.
1257
+ * - **A dropped subscription degrades to the timer and says so.** Report it
1258
+ * through {@link SubscribeChangesOptions.onError} and stop; the operator
1259
+ * logs one line and re-subscribes on its next round. Losing the stream
1260
+ * slows detection back to the interval. It must never stop the loop, and
1261
+ * must never read as a clean estate.
1262
+ * - **The timer stays.** A signal shortens the sleep, it does not replace
1263
+ * it, so a substrate that silently stops sending still converges on the
1264
+ * interval.
1265
+ *
1266
+ * Only implement it where the substrate has a change stream that needs
1267
+ * nothing deployed into the account being observed. See the per-lexicon
1268
+ * verdict table in the operator guide for where that holds and where it does
1269
+ * not.
1270
+ */
1271
+ subscribeChanges?(options: SubscribeChangesOptions): Promise<ChangeSubscription>;
1272
+
1149
1273
  /**
1150
1274
  * Read the full live *property tree* for each declared entity (#1014). Opt-in,
1151
1275
  * and strictly deeper than {@link describeResources}, which reports existence
@@ -1568,6 +1692,56 @@ export interface DependencyObservation {
1568
1692
  edges?: IREdge[];
1569
1693
  }
1570
1694
 
1695
+ /**
1696
+ * What {@link LexiconPlugin.subscribeChanges} is handed (#1981).
1697
+ *
1698
+ * `onChange` is the whole payload channel, and it has no payload. That is the
1699
+ * point: a substrate's change notification is a reason to look again, never
1700
+ * evidence of what is there. An implementation that wanted to pass the watch
1701
+ * event through would have nowhere to put it.
1702
+ */
1703
+ export interface SubscribeChangesOptions {
1704
+ /** chant environment being watched. Resolves the same binding a read does. */
1705
+ environment: string;
1706
+ /** Directory whose `chant.config.ts` carries the binding. Defaults to cwd. */
1707
+ cwd?: string;
1708
+ /**
1709
+ * Declared entities for this lexicon, keyed by chant entity name. The same
1710
+ * map {@link LexiconPlugin.describeResources} receives, and the bound on
1711
+ * what a subscription may watch. An implementation scopes its streams to the
1712
+ * kinds and namespaces these entities name; it must never subscribe to the
1713
+ * whole substrate. Absent, or empty, means there is nothing in scope to
1714
+ * watch and the implementation should say so through {@link onError} rather
1715
+ * than widening.
1716
+ */
1717
+ entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
1718
+ /**
1719
+ * Something moved. No arguments, deliberately (see {@link
1720
+ * LexiconPlugin.subscribeChanges}). The caller re-observes from scratch and
1721
+ * nothing about the notification reaches what it reports.
1722
+ */
1723
+ onChange(): void;
1724
+ /**
1725
+ * The subscription died, or could not be established for part of its scope.
1726
+ * Reported once per occurrence; the caller logs it and falls back to its
1727
+ * timer. Never a throw once the subscription is live: a stream that ends is
1728
+ * a degradation, not a crash.
1729
+ */
1730
+ onError?(message: string): void;
1731
+ /** Aborts the subscription. Closing on abort is the implementation's job. */
1732
+ signal: AbortSignal;
1733
+ }
1734
+
1735
+ /**
1736
+ * A live subscription handed back by {@link LexiconPlugin.subscribeChanges}
1737
+ * (#1981). `close()` releases every stream the subscription holds and resolves
1738
+ * once they are gone; it must be safe to call twice, and safe to call after
1739
+ * the subscription has already died on its own.
1740
+ */
1741
+ export interface ChangeSubscription {
1742
+ close(): Promise<void>;
1743
+ }
1744
+
1571
1745
  export interface ResourceMetadata {
1572
1746
  /** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
1573
1747
  type: string;
@@ -142,7 +142,7 @@ export interface PendingGateRecord {
142
142
  kind: "pending";
143
143
  /** The op (or, on the component driver, the component) the gate belongs to. */
144
144
  op: string;
145
- /** The gate's signal name (matches `GateStep.signalName`). */
145
+ /** The gate's name (matches `GateStep.gate`). */
146
146
  gate: string;
147
147
  /** The gate's human-readable description, when it declared one — what `chant operator status` shows a reader who wasn't there for the run. */
148
148
  description?: string;
@@ -173,6 +173,17 @@ function filename(op: string): string {
173
173
  return `${op}.jsonl`;
174
174
  }
175
175
 
176
+ /**
177
+ * Where an Op's gate ledger lives, as a path a human can go and read:
178
+ * `_gates/<op>.jsonl` on the `chant/lifecycle` orphan branch. Exported so a
179
+ * renderer that tells someone a gate is pending can name the file the pending
180
+ * fact was appended to (#2243) rather than describing it in prose that drifts
181
+ * from `DIR`.
182
+ */
183
+ export function gateLedgerPath(op: string): string {
184
+ return `${DIR}/${filename(op)}`;
185
+ }
186
+
176
187
  /** Append one immutable gate-resolution record. Does not push to the remote — call `pushLifecycle` (./git.ts) afterward, same two-step shape every other ledger write here uses. Retries on `RefCASConflictError` the same way `appendConvergeRecord` does (./converge-ledger.ts) — a concurrent writer to a different op's/env's file on the same orphan branch is the ordinary case, not an edge case. The baseline read must be `readPathSha` + `readBlobBySha` rather than `readBlobFromPath`, so the exact sha `existing` came from can be passed as `expectPriorPathSha` — see `writeBlobToPath` (./git.ts) for the race that closes. */
177
188
  export async function appendGateResolution(
178
189
  input: GateResolutionInput,
@@ -44,7 +44,7 @@ describe("classifyComponentPipelineChange (#1569)", () => {
44
44
 
45
45
  test("gate steps are never mistaken for an escape hatch", () => {
46
46
  const withGate = component([
47
- { phase: "Approve", steps: [{ kind: "gate", signalName: "release-approved" }] },
47
+ { phase: "Approve", steps: [{ kind: "gate", gate: "release-approved" }] },
48
48
  ]);
49
49
  expect(classifyComponentPipelineChange(withGate, { knownKinds: KNOWN })).toEqual({
50
50
  pipelineChange: false,
@@ -78,7 +78,7 @@ describe("componentVerbSet (#1569)", () => {
78
78
  const c = component([
79
79
  {
80
80
  phase: "Deploy",
81
- steps: [{ kind: "cfn-deploy" }, { kind: "gate", signalName: "go" }, { kind: "wait-for-stack" }],
81
+ steps: [{ kind: "cfn-deploy" }, { kind: "gate", gate: "go" }, { kind: "wait-for-stack" }],
82
82
  },
83
83
  ]);
84
84
  expect(componentVerbSet(c)).toEqual(new Set(["cfn-deploy", "wait-for-stack"]));
@@ -267,6 +267,32 @@ describe("COMP004: gate-needs-durable-runtime", () => {
267
267
  const hits = diagnostics.filter((d) => d.checkId === "COMP004" && d.component === "neo4j-cluster");
268
268
  expect(hits).toHaveLength(1);
269
269
  });
270
+
271
+ // #2202: the message names the gate whichever key carries the name, so the
272
+ // "chant approve <component> <gate>" line it prints stays copy-pasteable.
273
+ it("names a gate still using the deprecated `signalName` key", () => {
274
+ const [comp004] = checks.filter((c) => c.id === "COMP004");
275
+ const ctx = {
276
+ rollbackPolicies: FIXTURE_ROLLBACK_POLICIES,
277
+ components: new Map([
278
+ [
279
+ "svc",
280
+ {
281
+ component: {
282
+ name: "svc",
283
+ dependsOn: [],
284
+ deploy: [{ phase: "Approve", steps: [{ kind: "gate", signalName: "release-approval" }] }],
285
+ },
286
+ filePath: "svc.component.ts",
287
+ },
288
+ ],
289
+ ]),
290
+ };
291
+ const diagnostics = comp004.check(ctx as never);
292
+ expect(diagnostics).toHaveLength(1);
293
+ expect(diagnostics[0].message).toContain('gate "release-approval"');
294
+ expect(diagnostics[0].message).toContain("chant approve svc release-approval");
295
+ });
270
296
  });
271
297
 
272
298
  describe("COMP005: capability-kind-is-noun", () => {
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import type { ComponentCheck, ComponentCheckContext, ComponentCheckDiagnostic } from "../../component-checks";
44
+ import { gateName } from "../../../op/gate-name";
44
45
  import { walkComponent } from "./support";
45
46
 
46
47
  export const comp004GateNeedsDurableRuntimeRule: ComponentCheck = {
@@ -54,15 +55,16 @@ export const comp004GateNeedsDurableRuntimeRule: ComponentCheck = {
54
55
  for (const [name, { component, filePath }] of ctx.components) {
55
56
  const { gates } = walkComponent(component);
56
57
  for (const { gate, phaseName } of gates) {
58
+ const label = gateName(gate);
57
59
  diagnostics.push({
58
60
  checkId: "COMP004",
59
61
  severity: "error",
60
62
  component: name,
61
63
  file: filePath,
62
64
  message:
63
- `Component "${name}": gate "${gate.signalName}" (phase "${phaseName}") ends the run pending approval — ` +
65
+ `Component "${name}": gate "${label}" (phase "${phaseName}") ends the run pending approval — ` +
64
66
  `a run that reaches it records the gate as a fact and stops there until someone runs ` +
65
- `"chant approve ${name} ${gate.signalName}", and no later phase runs. If that wait is intended, ` +
67
+ `"chant approve ${name} ${label}", and no later phase runs. If that wait is intended, ` +
66
68
  `suppress with a file-level "// chant-disable COMP004 -- <reason>" comment anywhere in this file to ` +
67
69
  `document who approves it and why.`,
68
70
  });
@@ -72,7 +72,7 @@ function destructiveOpEntity(name: string, opts?: { gated?: boolean }) {
72
72
  ];
73
73
  const phases = opts?.gated
74
74
  ? [
75
- { name: "Approve", steps: [{ kind: "gate", signalName: "approve-x" }] },
75
+ { name: "Approve", steps: [{ kind: "gate", gate: "approve-x" }] },
76
76
  { name: "Apply", steps },
77
77
  ]
78
78
  : [{ name: "Apply", steps }];
@@ -43,8 +43,8 @@ export type { EnvTeardownArgs, EnvTeardownResult, EnvTeardownDeps } from "./env-
43
43
  // the registry imports this module statically, so a project that installs
44
44
  // nothing but chant still resolves every step below.
45
45
 
46
- export { reconcilePr } from "./reconcile";
47
- export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry } from "./reconcile";
46
+ export { reconcilePr, commentMarker, pullRequestContextFrom, resolvePullRequestContext } from "./reconcile";
47
+ export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry, PullRequestContext } from "./reconcile";
48
48
 
49
49
  export { nativeApply, compensateApply, hasNativeRollback } from "./apply";
50
50
  export type {
@@ -1,5 +1,12 @@
1
- import { describe, test, expect } from "vitest";
2
- import { reconcilePr, reconcileSummary, reconcileBranchName, entriesFromPlan } from "./reconcile";
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import {
3
+ reconcilePr,
4
+ reconcileSummary,
5
+ reconcileBranchName,
6
+ entriesFromPlan,
7
+ commentMarker,
8
+ pullRequestContextFrom,
9
+ } from "./reconcile";
3
10
 
4
11
  const entries = [
5
12
  { name: "bucket", action: "adopt", type: "AWS::S3::Bucket" },
@@ -79,3 +86,76 @@ describe("reconcilePr pre-built body (#2087)", () => {
79
86
  expect(result.entries).toEqual(entries);
80
87
  });
81
88
  });
89
+
90
+ describe("reconcilePr comment mode: the marker (#2231)", () => {
91
+ test("the marker is deterministic per env, so a re-run finds its own comment", () => {
92
+ expect(commentMarker("app")).toBe("<!-- chant-reconcile:app -->");
93
+ expect(commentMarker("app")).toBe(commentMarker("app"));
94
+ expect(commentMarker("app")).not.toBe(commentMarker("db"));
95
+ });
96
+
97
+ test("the marker slugifies the env, so nothing it is interpolated next to can be escaped out of", () => {
98
+ // The marker is interpolated into a jq `startswith("…")` string and into a
99
+ // shell word. A quote or a backslash surviving into it would break both.
100
+ const marker = commentMarker('us-east/1" or true; #');
101
+ expect(marker).toBe("<!-- chant-reconcile:us-east-1-or-true- -->");
102
+ expect(marker).not.toMatch(/["'\\]/);
103
+ });
104
+ });
105
+
106
+ describe("pullRequestContextFrom (#2231)", () => {
107
+ const repo = "INTENTIUS/chant";
108
+
109
+ test("reads the number off a pull_request event payload", () => {
110
+ expect(pullRequestContextFrom({ GITHUB_REPOSITORY: repo }, { number: 2231 })).toEqual({
111
+ repo,
112
+ number: 2231,
113
+ });
114
+ });
115
+
116
+ test("accepts the nested pull_request.number the same payload also carries", () => {
117
+ expect(
118
+ pullRequestContextFrom({ GITHUB_REPOSITORY: repo }, { pull_request: { number: 7 } }),
119
+ ).toEqual({ repo, number: 7 });
120
+ });
121
+
122
+ test("falls back to GITHUB_REF when the payload is unreadable", () => {
123
+ expect(
124
+ pullRequestContextFrom({ GITHUB_REPOSITORY: repo, GITHUB_REF: "refs/pull/42/merge" }),
125
+ ).toEqual({ repo, number: 42 });
126
+ });
127
+
128
+ test("a push run has no pull request", () => {
129
+ expect(
130
+ pullRequestContextFrom(
131
+ { GITHUB_REPOSITORY: repo, GITHUB_REF: "refs/heads/main" },
132
+ { ref: "refs/heads/main", after: "abc" },
133
+ ),
134
+ ).toBeUndefined();
135
+ });
136
+
137
+ test("a cron run off any forge has neither variable", () => {
138
+ expect(pullRequestContextFrom({})).toBeUndefined();
139
+ expect(pullRequestContextFrom({ GITHUB_REF: "refs/pull/1/merge" })).toBeUndefined();
140
+ });
141
+ });
142
+
143
+ describe("reconcilePr comment mode refuses a run with no pull request (#2231)", () => {
144
+ test("the message names the mode and every variable it looked for", async () => {
145
+ vi.stubEnv("GITHUB_REPOSITORY", "");
146
+ vi.stubEnv("GITHUB_REF", "");
147
+ vi.stubEnv("GITHUB_EVENT_PATH", "");
148
+ try {
149
+ // No `gh` is ever reached: the context check runs first, so a failure
150
+ // here is the refusal rather than a missing binary.
151
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
152
+ /mode "comment".*GITHUB_REPOSITORY.*GITHUB_EVENT_PATH.*GITHUB_REF/s,
153
+ );
154
+ await expect(reconcilePr({ env: "app", mode: "comment", body: "plan" })).rejects.toThrow(
155
+ /findingMode "issue" or "report"/,
156
+ );
157
+ } finally {
158
+ vi.unstubAllEnvs();
159
+ }
160
+ });
161
+ });