@intentius/chant 0.58.0 → 0.60.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 (169) 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/build-params.d.ts +2 -2
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/build.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts +13 -0
  10. package/dist/cli/commands/lint.d.ts.map +1 -1
  11. package/dist/cli/handlers/lint.d.ts.map +1 -1
  12. package/dist/cli/handlers/operator.d.ts.map +1 -1
  13. package/dist/cli/handlers/run.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts +0 -16
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/plugins.d.ts +22 -0
  17. package/dist/cli/plugins.d.ts.map +1 -1
  18. package/dist/cli/registry.d.ts +14 -0
  19. package/dist/cli/registry.d.ts.map +1 -1
  20. package/dist/components/cli-support.d.ts +4 -1
  21. package/dist/components/cli-support.d.ts.map +1 -1
  22. package/dist/components/component.d.ts +19 -4
  23. package/dist/components/component.d.ts.map +1 -1
  24. package/dist/components/driver.d.ts +8 -2
  25. package/dist/components/driver.d.ts.map +1 -1
  26. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  27. package/dist/components/verbs/run-agent.d.ts +1 -7
  28. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  29. package/dist/config.d.ts +4 -4
  30. package/dist/detectLexicon.d.ts +13 -0
  31. package/dist/detectLexicon.d.ts.map +1 -1
  32. package/dist/lexicon.d.ts +213 -3
  33. package/dist/lexicon.d.ts.map +1 -1
  34. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  35. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts +4 -4
  37. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  38. package/dist/op/activities/index.d.ts +2 -2
  39. package/dist/op/activities/index.d.ts.map +1 -1
  40. package/dist/op/activities/reconcile.d.ts +134 -2
  41. package/dist/op/activities/reconcile.d.ts.map +1 -1
  42. package/dist/op/builders.d.ts +2 -2
  43. package/dist/op/builders.d.ts.map +1 -1
  44. package/dist/op/change-signal.d.ts +91 -0
  45. package/dist/op/change-signal.d.ts.map +1 -0
  46. package/dist/op/composites/apply-op.d.ts +7 -2
  47. package/dist/op/composites/apply-op.d.ts.map +1 -1
  48. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  49. package/dist/op/gate-name.d.ts +40 -0
  50. package/dist/op/gate-name.d.ts.map +1 -0
  51. package/dist/op/gate-summary.d.ts +55 -0
  52. package/dist/op/gate-summary.d.ts.map +1 -0
  53. package/dist/op/index.d.ts +6 -2
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/op-ir.d.ts +2 -1
  57. package/dist/op/op-ir.d.ts.map +1 -1
  58. package/dist/op/operator.d.ts +63 -0
  59. package/dist/op/operator.d.ts.map +1 -1
  60. package/dist/op/types.d.ts +18 -3
  61. package/dist/op/types.d.ts.map +1 -1
  62. package/dist/params.d.ts +1 -1
  63. package/dist/project-root.d.ts +2 -2
  64. package/dist/terraform/bridge.d.ts +26 -7
  65. package/dist/terraform/bridge.d.ts.map +1 -1
  66. package/dist/terraform/carve-provider.d.ts +28 -2
  67. package/dist/terraform/carve-provider.d.ts.map +1 -1
  68. package/dist/terraform/data-source-shape.d.ts +77 -0
  69. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  70. package/dist/terraform/graph.d.ts.map +1 -1
  71. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  72. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  73. package/dist/terraform/tier-map.d.ts +16 -4
  74. package/dist/terraform/tier-map.d.ts.map +1 -1
  75. package/dist/terraform/types.d.ts +8 -0
  76. package/dist/terraform/types.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/audit/core.ts +25 -3
  79. package/src/audit/discover.ts +122 -14
  80. package/src/build-params.ts +2 -2
  81. package/src/cli/commands/audit.test.ts +5 -3
  82. package/src/cli/commands/audit.ts +5 -1
  83. package/src/cli/commands/build.test.ts +39 -1
  84. package/src/cli/commands/build.ts +22 -8
  85. package/src/cli/commands/carve-bridge.test.ts +237 -14
  86. package/src/cli/commands/carve-bridge.ts +21 -17
  87. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  88. package/src/cli/commands/lint.test.ts +297 -1
  89. package/src/cli/commands/lint.ts +119 -11
  90. package/src/cli/handlers/graph.test.ts +4 -4
  91. package/src/cli/handlers/graph.ts +11 -11
  92. package/src/cli/handlers/lint.test.ts +107 -0
  93. package/src/cli/handlers/lint.ts +30 -0
  94. package/src/cli/handlers/operator.ts +81 -0
  95. package/src/cli/handlers/run.test.ts +132 -3
  96. package/src/cli/handlers/run.ts +87 -3
  97. package/src/cli/main.test.ts +9 -11
  98. package/src/cli/main.ts +5 -27
  99. package/src/cli/plugins.test.ts +68 -2
  100. package/src/cli/plugins.ts +39 -0
  101. package/src/cli/registry.ts +14 -0
  102. package/src/components/README.md +2 -2
  103. package/src/components/SPRAWL-VALIDATION.md +5 -5
  104. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  105. package/src/components/cli-support.test.ts +18 -7
  106. package/src/components/cli-support.ts +8 -3
  107. package/src/components/component-schema.test.ts +18 -2
  108. package/src/components/component.schema.json +17 -4
  109. package/src/components/component.test.ts +2 -2
  110. package/src/components/component.ts +25 -5
  111. package/src/components/config-defaults.test.ts +2 -2
  112. package/src/components/driver.test.ts +20 -1
  113. package/src/components/driver.ts +12 -5
  114. package/src/components/pilots/README.md +1 -1
  115. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  116. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  117. package/src/components/verbs/run-agent.test.ts +19 -0
  118. package/src/components/verbs/run-agent.ts +1 -7
  119. package/src/config.ts +4 -4
  120. package/src/detectLexicon.ts +18 -1
  121. package/src/discovery/fold-import.test.ts +2 -2
  122. package/src/discovery/fold-import.ts +3 -3
  123. package/src/fold/foldable-helpers.ts +1 -1
  124. package/src/graph-ops.test.ts +1 -1
  125. package/src/lexicon.ts +221 -3
  126. package/src/lifecycle/gate-ledger.ts +12 -1
  127. package/src/lifecycle/observe.test.ts +2 -2
  128. package/src/lifecycle/observe.ts +8 -8
  129. package/src/lifecycle/release-ledger.test.ts +2 -2
  130. package/src/lint/pipeline-change-gate.test.ts +2 -2
  131. package/src/lint/rules/comp/comp.test.ts +26 -0
  132. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  133. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  134. package/src/op/activities/index.ts +9 -2
  135. package/src/op/activities/reconcile.test.ts +320 -2
  136. package/src/op/activities/reconcile.ts +423 -2
  137. package/src/op/builders.ts +3 -3
  138. package/src/op/change-signal.test.ts +117 -0
  139. package/src/op/change-signal.ts +169 -0
  140. package/src/op/composites/apply-op.ts +14 -4
  141. package/src/op/composites/composites.test.ts +17 -4
  142. package/src/op/composites/reconcile-op.ts +7 -4
  143. package/src/op/effect-step.test.ts +3 -3
  144. package/src/op/gate-name.test.ts +65 -0
  145. package/src/op/gate-name.ts +60 -0
  146. package/src/op/gate-summary.test.ts +62 -0
  147. package/src/op/gate-summary.ts +96 -0
  148. package/src/op/index.ts +9 -2
  149. package/src/op/local-executor.test.ts +16 -1
  150. package/src/op/local-executor.ts +4 -3
  151. package/src/op/op-ir.test.ts +12 -1
  152. package/src/op/op-ir.ts +5 -3
  153. package/src/op/op-verb-class.test.ts +2 -2
  154. package/src/op/op.test.ts +2 -2
  155. package/src/op/operator.test.ts +368 -0
  156. package/src/op/operator.ts +141 -5
  157. package/src/op/runtimes/local.test.ts +1 -1
  158. package/src/op/types.ts +23 -3
  159. package/src/params.ts +1 -1
  160. package/src/project-root.ts +2 -2
  161. package/src/terraform/aws-resources.test.ts +13 -4
  162. package/src/terraform/bridge.test.ts +22 -9
  163. package/src/terraform/bridge.ts +89 -28
  164. package/src/terraform/carve-provider.ts +38 -2
  165. package/src/terraform/data-source-shape.ts +95 -0
  166. package/src/terraform/graph.ts +35 -7
  167. package/src/terraform/providers/kubernetes.ts +48 -2
  168. package/src/terraform/tier-map.ts +21 -6
  169. package/src/terraform/types.ts +8 -0
package/src/config.ts CHANGED
@@ -481,10 +481,10 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
481
481
  * lives at the project root, one or more levels up. Before this, callers
482
482
  * either read `startDir` alone or bolted on a single `dirname()` fallback —
483
483
  * fine for a one-level-deep stack, silently blind to anything deeper
484
- * (loomster's `src/<stack>` layout is exactly one level too deep: `buildParams`'
485
- * declared `env:` mappings never resolved, so `LOOM_TIER`/`LOOM_ENV` were inert
486
- * under every `npm run synth:*` for two releases — loomster#162). Uses the
487
- * same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
484
+ * (a `src/<stack>` layout is exactly one level too deep: `buildParams`' declared
485
+ * `env:` mappings never resolved, so the env vars they named were inert under
486
+ * every `npm run synth:*` for two releases). Uses the same walk `chant
487
+ * lint`/`chant graph` already used ({@link findProjectConfig},
488
488
  * shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
489
489
  * contract for the whole CLI.
490
490
  *
@@ -1,5 +1,22 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ /**
4
+ * The message {@link detectLexicons} throws when a scan of the project's
5
+ * source files turns up no `@intentius/chant-lexicon-*` import at all.
6
+ *
7
+ * Named (chant #2222) because one caller has to tell this failure apart from
8
+ * every other one: `chant lint` reports a lexicon it cannot resolve as an
9
+ * error diagnostic, but "this directory declares no lexicon and imports none"
10
+ * is not that. It is a project that lints under the core rules alone, and it
11
+ * has always been allowed to. See {@link isNoLexiconDetected}.
12
+ */
13
+ export const NO_LEXICON_DETECTED_MESSAGE = "No lexicon detected in infrastructure files";
14
+
15
+ /** Whether `error` is the {@link NO_LEXICON_DETECTED_MESSAGE} failure. */
16
+ export function isNoLexiconDetected(error: unknown): boolean {
17
+ return error instanceof Error && error.message === NO_LEXICON_DETECTED_MESSAGE;
18
+ }
19
+
3
20
  /**
4
21
  * Detects which lexicons are being used by analyzing import statements
5
22
  * in the provided infrastructure files. Matches any `@intentius/chant-lexicon-*` package.
@@ -32,7 +49,7 @@ export async function detectLexicons(files: string[]): Promise<string[]> {
32
49
 
33
50
  // Validate results
34
51
  if (detectedLexicons.size === 0) {
35
- throw new Error("No lexicon detected in infrastructure files");
52
+ throw new Error(NO_LEXICON_DETECTED_MESSAGE);
36
53
  }
37
54
 
38
55
  return Array.from(detectedLexicons);
@@ -1009,7 +1009,7 @@ describe("tryFoldFile — build-time parameters (chant #1064)", () => {
1009
1009
  expect((entity as unknown as { props: { name: unknown } }).props.name).toBe("staging");
1010
1010
  });
1011
1011
 
1012
- test("a nullish-coalesced default still folds to a literal (loomster's `params.x ?? \"default\"` pattern)", async () => {
1012
+ test("a nullish-coalesced default still folds to a literal (the `params.x ?? \"default\"` pattern)", async () => {
1013
1013
  const file = join(testDir, "main.ts");
1014
1014
  await writeFile(
1015
1015
  file,
@@ -1276,7 +1276,7 @@ describe("tryFoldFile — registered authoring helpers (#1082)", () => {
1276
1276
  stack: "web",
1277
1277
  inputs: { pVpcId: { stackOutput: { stack: "shared-foundation", name: "oVpcId" } } },
1278
1278
  },
1279
- { kind: "gate", signalName: "approve", timeout: "24h" },
1279
+ { kind: "gate", gate: "approve", timeout: "24h" },
1280
1280
  ],
1281
1281
  },
1282
1282
  ],
@@ -1697,9 +1697,9 @@ async function resolveCallArguments(
1697
1697
  // 4. **Its body is a single expression, or a block of `const` declarations
1698
1698
  // followed by one `return`** — and nothing else. No `if`, no `throw`, no
1699
1699
  // loop, no `let`/`var`, no nested function declaration, no bare expression
1700
- // statement. This is the line loomster's `composites/*.ts` fall outside
1701
- // (module-level `buildXxx()` helpers with `if`/`throw` and `.map()`), and
1702
- // they are meant to: they keep invoking, exactly as before.
1700
+ // statement. This is the line a project's hand-rolled composite modules
1701
+ // fall outside (module-level `buildXxx()` helpers with `if`/`throw` and
1702
+ // `.map()`), and they are meant to: they keep invoking, exactly as before.
1703
1703
  // 5. **Every expression in it is inside the fold subset, extended with the
1704
1704
  // two things a factory body exists to do**: `new Type(...)` in ANY value
1705
1705
  // position (a member, a nested property object, an array element), and a
@@ -123,7 +123,7 @@ export const FOLDABLE_AUTHORING_HELPERS: readonly FoldableHelperDef[] = [
123
123
  {
124
124
  name: "gate",
125
125
  module: "components/component.ts, op/builders.ts",
126
- note: "Returns a plain `{ kind: 'gate', signalName, ... }` object literal built from its arguments.",
126
+ note: "Returns a plain `{ kind: 'gate', gate, ... }` object literal built from its arguments.",
127
127
  },
128
128
  {
129
129
  name: "activity",
@@ -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,15 @@ 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 — or, on GitLab, the merge
498
+ * request (#2256) — that triggered the run, so unlike every other mode it
499
+ * constrains the trigger: the github and gitlab generators both refuse it by
500
+ * name on anything but `pull_request`. The forgejo generator refuses it
501
+ * outright, having no forge client to post the equivalent comment with.
496
502
  */
497
- export type OpFindingMode = "report" | "issue" | "pull-request" | "merge-request";
503
+ export type OpFindingMode = "report" | "issue" | "comment" | "pull-request" | "merge-request";
498
504
 
499
505
  /**
500
506
  * The CI-native trigger driving a scheduled Op's generated workflow. `cron`
@@ -509,6 +515,77 @@ export type OpTrigger =
509
515
  | { kind: "pull_request"; branches?: string[] }
510
516
  | { kind: "push"; branches?: string[] };
511
517
 
518
+ /**
519
+ * One step a generated Op job runs between the checkout and the
520
+ * `beforeScript` lines (#2242). Two shapes, matching what a GitHub Actions
521
+ * step can be: a marketplace action (`uses`, with its `with:` inputs and
522
+ * `env:`), or a shell line (`run`). The `uses` shape is the reason this
523
+ * exists at all: `beforeScript` covers everything a shell line can install,
524
+ * but an action like `aws-actions/configure-aws-credentials` is not a shell
525
+ * line, and OIDC has no shell equivalent.
526
+ *
527
+ * A CI provider with no action concept degrades by name rather than
528
+ * silently: the gitlab generator refuses a `uses` entry at build time and
529
+ * emits a `run` entry as an ordinary script line. The additive `permissions`
530
+ * map degrades the same way there, with one exception: `id-token: write`
531
+ * becomes GitLab's own `id_tokens:` declaration (#2256).
532
+ */
533
+ export type OpSetupStep = OpSetupUsesStep | OpSetupRunStep;
534
+
535
+ /** A marketplace-action setup step (`uses:` with its inputs). */
536
+ export interface OpSetupUsesStep {
537
+ /**
538
+ * `owner/repo[/subpath]@ref`. The ref is required and must not be the
539
+ * action repository's own default branch — see the github generator's
540
+ * `assertSetupSteps`, which refuses both at build time.
541
+ */
542
+ uses: string;
543
+ /** The action's inputs, emitted as the step's `with:` mapping. */
544
+ with?: Record<string, string | number | boolean>;
545
+ /** Environment for this step alone, emitted as the step's `env:` mapping. */
546
+ env?: Record<string, string>;
547
+ }
548
+
549
+ /** A shell setup step, the same shape a `beforeScript` line emits as. */
550
+ export interface OpSetupRunStep {
551
+ /** The shell line, emitted as the step's `run:`. */
552
+ run: string;
553
+ /** Environment for this step alone, emitted as the step's `env:` mapping. */
554
+ env?: Record<string, string>;
555
+ }
556
+
557
+ /**
558
+ * The deployment environment a generated Op job runs in (#2257) — a forge
559
+ * object rather than a chant one. On GitHub Actions an environment carries
560
+ * its own protection rules (required reviewers, a wait timer, a branch
561
+ * restriction) and its own secrets and variables, so naming one on a job is
562
+ * how a generated apply is put behind a human before the job starts. GitLab
563
+ * has the same key with the same two fields and its own protected-environment
564
+ * approvals behind it. Forgejo Actions has no environments at all, so its
565
+ * dialect drops the key and says so in the generated file's header.
566
+ *
567
+ * This is not a chant gate and does not replace one. The environment reviewer
568
+ * stops the job before any step runs; chant's own gate (#2119) stops the apply
569
+ * inside a run that already started, on a fact recorded on the
570
+ * `chant/lifecycle` branch, and is cleared by `chant approve`. A project may
571
+ * have either, both, or neither per environment.
572
+ */
573
+ export interface OpEnvironment {
574
+ /**
575
+ * The environment's name, exactly as the forge spells it. Nothing creates
576
+ * it: an environment is repository configuration, and a job naming one that
577
+ * does not exist yet gets an unprotected environment created on first run
578
+ * rather than an error, which is precisely why the name is not guessed here.
579
+ */
580
+ name: string;
581
+ /**
582
+ * The URL shown against the resulting deployment. Absolute, or an
583
+ * expression the forge resolves (`${{ ... }}`) — a relative path renders as
584
+ * a dead link on the deployments page rather than failing anywhere.
585
+ */
586
+ url?: string;
587
+ }
588
+
512
589
  /** One scheduled Op to generate CI for — the cron-triggered counterpart to a component (generate mode). */
513
590
  export interface ScheduledOpSpec {
514
591
  /** Op name (`*.op.ts`'s `Op({ name })`) — what `chant run <name>` targets. */
@@ -535,6 +612,32 @@ export interface ScheduledOpSpec {
535
612
  opSchedule?: OpSchedule;
536
613
  /** This Op's finding-mode, for permission/token wiring only (see {@link OpFindingMode}). Default: "report" — no elevated permissions. */
537
614
  findingMode?: OpFindingMode;
615
+ /**
616
+ * Steps this Op's generated job runs between the checkout and the
617
+ * `beforeScript` lines (#2242), in the order given. Per-Op rather than a
618
+ * `ComponentPipelineOptions` knob because the setup an Op needs is a
619
+ * property of that Op: a plan job assumes a read-only role, an apply job
620
+ * assumes the one that can write.
621
+ */
622
+ setup?: OpSetupStep[];
623
+ /**
624
+ * Scopes added to the ones this Op's finding-mode already grants (#2242).
625
+ * Strictly additive: the generator refuses a scope the mode's own set
626
+ * already names, at any value, so this can neither downgrade nor restate
627
+ * least privilege, and it refuses `write-all`/`read-all` outright. The
628
+ * motivating value is `{ "id-token": "write" }`, which no finding-mode
629
+ * grants and OIDC cannot work without.
630
+ */
631
+ permissions?: Record<string, "read" | "write">;
632
+ /**
633
+ * The forge deployment environment this Op's generated job runs in
634
+ * (#2257). Per Op, beside `setup` and `permissions`, and for the same
635
+ * reason: which environment a job deploys to is a property of that job — a
636
+ * pull-request plan touches none, and only the push apply belongs behind
637
+ * the reviewer. See {@link OpEnvironment} for how it composes with chant's
638
+ * own gate.
639
+ */
640
+ environment?: OpEnvironment;
538
641
  }
539
642
 
540
643
  /**
@@ -663,6 +766,29 @@ export type BuildRootContributor = (
663
766
  ctx: Pick<BuildRootContext, "entities">,
664
767
  ) => Promise<BuildRootContribution>;
665
768
 
769
+ /**
770
+ * Where the content handed to `auditEntities` came from (#2217).
771
+ *
772
+ * The hook's first argument is the text audit discovery classified for a
773
+ * lexicon. For most lexicons that is the whole unit of parsing. For terraform
774
+ * it is not: a root module's `module` blocks name sibling directories, and a
775
+ * rule about what a CHILD module may contain cannot be answered from one
776
+ * directory's text. So the discovery facts a lexicon may need to read further
777
+ * travel with the content.
778
+ *
779
+ * `dir` and `baseDir` are set only when discovery walked a local filesystem.
780
+ * A remote tree fetch leaves both undefined, and a lexicon that finds them
781
+ * undefined parses the content it was given and nothing else.
782
+ */
783
+ export interface AuditEntitiesInput {
784
+ /** The input's path as discovery recorded it, relative to the audited root. `"."` for the root itself. */
785
+ path: string;
786
+ /** Absolute path of the directory this input's files were read from. */
787
+ dir?: string;
788
+ /** Absolute path of the audited root: the boundary a lexicon may read within. */
789
+ baseDir?: string;
790
+ }
791
+
666
792
  export interface LexiconPlugin {
667
793
  // ── Required ──────────────────────────────────────────────
668
794
  /** Human-readable name (e.g. "aws", "gcp") */
@@ -791,8 +917,17 @@ export interface LexiconPlugin {
791
917
  * return a `Promise` for a lexicon whose parser is inherently async (e.g.
792
918
  * terraform's HCL parser, a lazy-loaded wasm module). `auditLexicon` awaits
793
919
  * it before reading `ctx.entities`.
920
+ *
921
+ * `input` says where the content came from (#2217). It is optional so a
922
+ * caller can still parse a bare string, and a lexicon that only needs the
923
+ * text ignores it. A lexicon whose unit of parsing is a directory rather
924
+ * than a file (terraform: a root module and the local modules it calls)
925
+ * reads `dir` to descend, and stays inside `baseDir` while doing it.
794
926
  */
795
- auditEntities?(content: string): Map<string, Declarable> | Promise<Map<string, Declarable>>;
927
+ auditEntities?(
928
+ content: string,
929
+ input?: AuditEntitiesInput,
930
+ ): Map<string, Declarable> | Promise<Map<string, Declarable>>;
796
931
 
797
932
  /**
798
933
  * Machine-readable spec-coverage accounting for `check-lexicon` (#1330).
@@ -1146,6 +1281,39 @@ export interface LexiconPlugin {
1146
1281
  region?: string;
1147
1282
  }): Promise<Record<string, ResourceMetadata>>;
1148
1283
 
1284
+ /**
1285
+ * Subscribe to this substrate's own change notifications (#1981), so an
1286
+ * operator tick can run when something moved instead of only when the timer
1287
+ * came round. Optional, and consumed by exactly one caller: `chant
1288
+ * operator`'s loop (`./op/operator.ts`). A lexicon that does not implement
1289
+ * it behaves exactly as it did: the loop keeps its timer and never asks.
1290
+ *
1291
+ * Three rules make the seam honest, and the types are shaped to enforce the
1292
+ * first one rather than describe it.
1293
+ *
1294
+ * - **The signal is a trigger, never a fact.** {@link
1295
+ * SubscribeChangesOptions.onChange} takes no arguments and returns
1296
+ * nothing, so there is no channel through which a watch event could reach
1297
+ * a change set, a snapshot row or a diff line. The tick that follows an
1298
+ * early wake re-observes and re-derives from scratch, exactly as a
1299
+ * timer-driven tick does; waking early changes *when* a tick runs and
1300
+ * nothing about what it concludes.
1301
+ * - **A dropped subscription degrades to the timer and says so.** Report it
1302
+ * through {@link SubscribeChangesOptions.onError} and stop; the operator
1303
+ * logs one line and re-subscribes on its next round. Losing the stream
1304
+ * slows detection back to the interval. It must never stop the loop, and
1305
+ * must never read as a clean estate.
1306
+ * - **The timer stays.** A signal shortens the sleep, it does not replace
1307
+ * it, so a substrate that silently stops sending still converges on the
1308
+ * interval.
1309
+ *
1310
+ * Only implement it where the substrate has a change stream that needs
1311
+ * nothing deployed into the account being observed. See the per-lexicon
1312
+ * verdict table in the operator guide for where that holds and where it does
1313
+ * not.
1314
+ */
1315
+ subscribeChanges?(options: SubscribeChangesOptions): Promise<ChangeSubscription>;
1316
+
1149
1317
  /**
1150
1318
  * Read the full live *property tree* for each declared entity (#1014). Opt-in,
1151
1319
  * and strictly deeper than {@link describeResources}, which reports existence
@@ -1568,6 +1736,56 @@ export interface DependencyObservation {
1568
1736
  edges?: IREdge[];
1569
1737
  }
1570
1738
 
1739
+ /**
1740
+ * What {@link LexiconPlugin.subscribeChanges} is handed (#1981).
1741
+ *
1742
+ * `onChange` is the whole payload channel, and it has no payload. That is the
1743
+ * point: a substrate's change notification is a reason to look again, never
1744
+ * evidence of what is there. An implementation that wanted to pass the watch
1745
+ * event through would have nowhere to put it.
1746
+ */
1747
+ export interface SubscribeChangesOptions {
1748
+ /** chant environment being watched. Resolves the same binding a read does. */
1749
+ environment: string;
1750
+ /** Directory whose `chant.config.ts` carries the binding. Defaults to cwd. */
1751
+ cwd?: string;
1752
+ /**
1753
+ * Declared entities for this lexicon, keyed by chant entity name. The same
1754
+ * map {@link LexiconPlugin.describeResources} receives, and the bound on
1755
+ * what a subscription may watch. An implementation scopes its streams to the
1756
+ * kinds and namespaces these entities name; it must never subscribe to the
1757
+ * whole substrate. Absent, or empty, means there is nothing in scope to
1758
+ * watch and the implementation should say so through {@link onError} rather
1759
+ * than widening.
1760
+ */
1761
+ entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
1762
+ /**
1763
+ * Something moved. No arguments, deliberately (see {@link
1764
+ * LexiconPlugin.subscribeChanges}). The caller re-observes from scratch and
1765
+ * nothing about the notification reaches what it reports.
1766
+ */
1767
+ onChange(): void;
1768
+ /**
1769
+ * The subscription died, or could not be established for part of its scope.
1770
+ * Reported once per occurrence; the caller logs it and falls back to its
1771
+ * timer. Never a throw once the subscription is live: a stream that ends is
1772
+ * a degradation, not a crash.
1773
+ */
1774
+ onError?(message: string): void;
1775
+ /** Aborts the subscription. Closing on abort is the implementation's job. */
1776
+ signal: AbortSignal;
1777
+ }
1778
+
1779
+ /**
1780
+ * A live subscription handed back by {@link LexiconPlugin.subscribeChanges}
1781
+ * (#1981). `close()` releases every stream the subscription holds and resolves
1782
+ * once they are gone; it must be safe to call twice, and safe to call after
1783
+ * the subscription has already died on its own.
1784
+ */
1785
+ export interface ChangeSubscription {
1786
+ close(): Promise<void>;
1787
+ }
1788
+
1571
1789
  export interface ResourceMetadata {
1572
1790
  /** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
1573
1791
  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,
@@ -204,8 +204,8 @@ describe("observeResources", () => {
204
204
  const stack = (opts as { stack?: string }).stack;
205
205
  calls.push(stack);
206
206
  // Different resources per stack — the multi-stack, per-component case
207
- // (#57 loomster). Bare-string stacks keep BARE ids (no `src` scope), so
208
- // the union is `db-a`+`db-b`, not stack-qualified: per-component ids are
207
+ // (#57). Bare-string stacks keep BARE ids (no `src` scope), so the
208
+ // union is `db-a`+`db-b`, not stack-qualified: per-component ids are
209
209
  // already unique and behold reads them bare. Qualification is a scoped
210
210
  // (`src`) feature — see the per-stack src test below.
211
211
  const resources: Record<string, ResourceMetadata> =
@@ -71,10 +71,10 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
71
71
  * (`read-failed`, #1089) rather than dropped, so a failed read is visibly a
72
72
  * hole instead of a silent absence.
73
73
  *
74
- * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
75
- * where there is no single stack named after the environment — AWS's
76
- * single-stack convention (`lexicons/aws/src/plugin.ts`'s `describeResources`,
77
- * absent an explicit `stack`) queries a stack that simply doesn't exist there,
74
+ * `stacks` (#57) is for a multi-stack, per-component project, where there is no
75
+ * single stack named after the environment — AWS's single-stack convention
76
+ * (`lexicons/aws/src/plugin.ts`'s `describeResources`, absent an explicit
77
+ * `stack`) queries a stack that simply doesn't exist there,
78
78
  * so the single-call path always observes zero nodes. When `stacks` is
79
79
  * present and non-empty, each observing plugin's `describeResources` is
80
80
  * called once per stack and the returned observations are merged. A stack entry
@@ -197,10 +197,10 @@ export async function observeResources(
197
197
  // Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
198
198
  // is the multi-region case where the SAME bare LogicalResourceId
199
199
  // (e.g. `vpc`) exists in every stack, so a bare union would collide.
200
- // A bare-string stack (#57 loomster) has unique per-component ids and
201
- // is asked the whole-project entity set, so it keeps the bare-id
202
- // tri-state merge (present > not-observed > absent) that behold and
203
- // other consumers read.
200
+ // A bare-string stack (#57) has unique per-component ids and is asked
201
+ // the whole-project entity set, so it keeps the bare-id tri-state
202
+ // merge (present > not-observed > absent) that behold and other
203
+ // consumers read.
204
204
  parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
205
205
  }
206
206
  observed = mergeObservations(parts);
@@ -256,10 +256,10 @@ describe("release-ledger", () => {
256
256
  test("a Forgejo instance resolves through the same env contract to its own host", () => {
257
257
  const env = {
258
258
  GITHUB_RUN_ID: "42",
259
- GITHUB_REPOSITORY: "intentius/loomster",
259
+ GITHUB_REPOSITORY: "acme/widgets",
260
260
  GITHUB_SERVER_URL: "https://forge.example.dev",
261
261
  };
262
- expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/intentius/loomster/actions/runs/42");
262
+ expect(resolveRunId(undefined, env).runOrigin!.url).toBe("https://forge.example.dev/acme/widgets/actions/runs/42");
263
263
  });
264
264
 
265
265
  test("GitLab CI env records the project path and takes CI_PIPELINE_URL verbatim", () => {
@@ -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,15 @@ 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 {
47
+ reconcilePr,
48
+ commentMarker,
49
+ pullRequestContextFrom,
50
+ resolvePullRequestContext,
51
+ mergeRequestContextFrom,
52
+ gitlabNoteTokenFrom,
53
+ } from "./reconcile";
54
+ export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry, PullRequestContext } from "./reconcile";
48
55
 
49
56
  export { nativeApply, compensateApply, hasNativeRollback } from "./apply";
50
57
  export type {