@intentius/chant 0.1.18 → 0.1.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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/build.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs } from "./build";
2
+ import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs, computeStackGraph } from "./build";
3
3
  import { output } from "./lexicon-output";
4
4
  import { AttrRef } from "./attrref";
5
5
  import { INTRINSIC_MARKER } from "./intrinsic";
@@ -462,3 +462,50 @@ describe("detectCrossLexiconRefs", () => {
462
462
  expect(detected[0].outputName).toBe("dataBucket_Endpoint");
463
463
  });
464
464
  });
465
+
466
+ describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
467
+ const ent = (lexicon: string, props: Record<string, unknown> = {}): Declarable =>
468
+ ({ lexicon, entityType: `${lexicon}::X`, [DECLARABLE_MARKER]: true, props }) as unknown as Declarable;
469
+
470
+ test("infers a consumer→producer edge from a cross-lexicon AttrRef", () => {
471
+ const vpc = ent("aws");
472
+ const svc = ent("k8s", { vpcId: new AttrRef(vpc, "id") });
473
+ const g = computeStackGraph(new Map([["vpc", vpc], ["svc", svc]]), ["aws", "k8s"]);
474
+
475
+ expect(g.edges).toEqual([{ from: "k8s", to: "aws" }]);
476
+ expect(g.order).toEqual(["aws", "k8s"]); // producer before consumer
477
+ expect(g.waves).toEqual([["aws"], ["k8s"]]); // separate waves — ordered
478
+ expect(g.cycles).toEqual([]);
479
+ });
480
+
481
+ test("independent stacks share a wave (parallel-safe)", () => {
482
+ const g = computeStackGraph(new Map([["a", ent("aws")], ["b", ent("gcp")]]), ["aws", "gcp"]);
483
+ expect(g.edges).toEqual([]);
484
+ expect(g.waves).toEqual([["aws", "gcp"]]); // one wave — no inter-dependency
485
+ });
486
+
487
+ test("reports a dependency cycle", () => {
488
+ const a = ent("x");
489
+ const b = ent("y", { ref: new AttrRef(a, "out") });
490
+ // close the loop: a references b
491
+ (a as unknown as { props: Record<string, unknown> }).props = { ref: new AttrRef(b, "out") };
492
+ const g = computeStackGraph(new Map([["a", a], ["b", b]]), ["x", "y"]);
493
+
494
+ expect(g.edges).toEqual(expect.arrayContaining([{ from: "x", to: "y" }, { from: "y", to: "x" }]));
495
+ expect(g.cycles).toEqual([["x", "y"]]);
496
+ expect(g.order).toEqual([]); // nothing is orderable inside a cycle
497
+ });
498
+
499
+ test("a diamond resolves into three waves", () => {
500
+ // base ← (left, right) ← top
501
+ const base = ent("base");
502
+ const left = ent("left", { b: new AttrRef(base, "id") });
503
+ const right = ent("right", { b: new AttrRef(base, "id") });
504
+ const top = ent("top", { l: new AttrRef(left, "id"), r: new AttrRef(right, "id") });
505
+ const g = computeStackGraph(
506
+ new Map([["base", base], ["left", left], ["right", right], ["top", top]]),
507
+ ["base", "left", "right", "top"],
508
+ );
509
+ expect(g.waves).toEqual([["base"], ["left", "right"], ["top"]]);
510
+ });
511
+ });
package/src/build.ts CHANGED
@@ -20,6 +20,115 @@ export interface BuildManifest {
20
20
  { source: string; entity: string; attribute: string }
21
21
  >;
22
22
  deployOrder: string[];
23
+ /** Cross-stack apply-ordering graph (see {@link computeStackGraph}). */
24
+ stackGraph: StackGraph;
25
+ }
26
+
27
+ /**
28
+ * The cross-stack (cross-lexicon) apply-ordering graph chant already computes
29
+ * while resolving cross-lexicon references — surfaced as tool-agnostic data for
30
+ * an orchestrator to consume. chant exposes the order; it does not drive the
31
+ * apply.
32
+ */
33
+ export interface StackGraph {
34
+ /** Stacks (lexicon partitions) in the build. */
35
+ nodes: string[];
36
+ /**
37
+ * Consumer→producer edges: `from` imports a value `to` exports, so `to` must
38
+ * apply before `from`. Inferred from cross-lexicon references.
39
+ */
40
+ edges: Array<{ from: string; to: string }>;
41
+ /** A flat applicable sequence — every producer before its consumers. */
42
+ order: string[];
43
+ /**
44
+ * Levels: stacks in the same wave have no inter-dependency and may apply
45
+ * concurrently. `order` flattened with parallelism made explicit.
46
+ */
47
+ waves: string[][];
48
+ /** Dependency cycles, if any (each a list of stacks). Normally empty. */
49
+ cycles: string[][];
50
+ }
51
+
52
+ /**
53
+ * Compute the cross-stack apply-ordering graph from resolved entities. Edges are
54
+ * inferred from cross-lexicon attribute references (a resource in lexicon A
55
+ * referencing an attribute of a resource in lexicon B ⇒ A depends on B). Returns
56
+ * the edge set plus a topological order, parallel-safe waves, and any cycles.
57
+ */
58
+ export function computeStackGraph(
59
+ entities: Map<string, Declarable>,
60
+ lexiconNames: string[],
61
+ ): StackGraph {
62
+ const edges: Array<{ from: string; to: string }> = [];
63
+ const edgeSet = new Set<string>();
64
+ const addEdge = (from: string, to: string): void => {
65
+ if (from === to) return;
66
+ const key = `${from}\0${to}`;
67
+ if (edgeSet.has(key)) return;
68
+ edgeSet.add(key);
69
+ edges.push({ from, to });
70
+ };
71
+
72
+ const walk = (value: unknown, consumer: string, visited: Set<unknown>): void => {
73
+ if (value === null || value === undefined || typeof value !== "object") return;
74
+ if (visited.has(value)) return;
75
+ visited.add(value);
76
+ if (value instanceof AttrRef) {
77
+ const parent = value.parent.deref();
78
+ const producer = parent ? (parent as Record<string, unknown>).lexicon : undefined;
79
+ if (typeof producer === "string" && producer !== consumer) addEdge(consumer, producer);
80
+ return;
81
+ }
82
+ if (isLexiconOutput(value)) return;
83
+ if (Array.isArray(value)) {
84
+ for (const item of value) walk(item, consumer, visited);
85
+ return;
86
+ }
87
+ for (const val of Object.values(value as Record<string, unknown>)) walk(val, consumer, visited);
88
+ };
89
+
90
+ for (const [, entity] of entities) {
91
+ const consumer = entity.lexicon;
92
+ const visited = new Set<unknown>();
93
+ for (const val of Object.values(entity as unknown as Record<string, unknown>)) {
94
+ walk(val, consumer, visited);
95
+ }
96
+ if ("props" in entity && typeof entity.props === "object" && entity.props !== null) {
97
+ walk(entity.props, consumer, visited);
98
+ }
99
+ }
100
+
101
+ // Dependency map: node → producers it depends on.
102
+ const nodes = lexiconNames.length
103
+ ? [...lexiconNames]
104
+ : [...new Set([...entities.values()].map((e) => e.lexicon))];
105
+ const deps = new Map<string, Set<string>>();
106
+ for (const n of nodes) deps.set(n, new Set());
107
+ for (const { from, to } of edges) {
108
+ if (!deps.has(from)) deps.set(from, new Set());
109
+ if (!deps.has(to)) deps.set(to, new Set());
110
+ deps.get(from)!.add(to);
111
+ }
112
+
113
+ // Kahn layering: a node is ready once every producer it depends on is placed.
114
+ const remaining = new Set(deps.keys());
115
+ const waves: string[][] = [];
116
+ const order: string[] = [];
117
+ while (remaining.size > 0) {
118
+ const wave = [...remaining]
119
+ .filter((n) => [...deps.get(n)!].every((d) => !remaining.has(d)))
120
+ .sort();
121
+ if (wave.length === 0) break; // remaining nodes form a cycle
122
+ for (const n of wave) {
123
+ remaining.delete(n);
124
+ order.push(n);
125
+ }
126
+ waves.push(wave);
127
+ }
128
+ const cycles: string[][] = remaining.size > 0 ? [[...remaining].sort()] : [];
129
+
130
+ edges.sort((a, b) => `${a.from}\0${a.to}`.localeCompare(`${b.from}\0${b.to}`));
131
+ return { nodes: [...nodes].sort(), edges, order, waves, cycles };
23
132
  }
24
133
 
25
134
  /**
@@ -288,7 +397,8 @@ function computeDeployOrder(
288
397
  */
289
398
  function generateManifest(
290
399
  lexiconNames: string[],
291
- lexiconOutputs: LexiconOutput[]
400
+ lexiconOutputs: LexiconOutput[],
401
+ entities: Map<string, Declarable>
292
402
  ): BuildManifest {
293
403
  const outputsRecord: Record<
294
404
  string,
@@ -307,6 +417,7 @@ function generateManifest(
307
417
  lexicons: lexiconNames,
308
418
  outputs: outputsRecord,
309
419
  deployOrder: computeDeployOrder(lexiconNames, lexiconOutputs),
420
+ stackGraph: computeStackGraph(entities, lexiconNames),
310
421
  };
311
422
  }
312
423
 
@@ -453,7 +564,7 @@ export async function build(
453
564
 
454
565
  // Step 8: Generate manifest
455
566
  const lexiconNames = Array.from(partitions.keys());
456
- const manifest = generateManifest(lexiconNames, lexiconOutputs);
567
+ const manifest = generateManifest(lexiconNames, lexiconOutputs, discoveryResult.entities);
457
568
 
458
569
  return {
459
570
  outputs,
@@ -3,7 +3,7 @@ import { loadChantConfig, resolveOwnershipMarker } from "../../config";
3
3
  import type { Serializer, SerializerResult } from "../../serializer";
4
4
  import type { LexiconPlugin } from "../../lexicon";
5
5
  import { runPostSynthChecks } from "../../lint/post-synth";
6
- import type { PostSynthCheck } from "../../lint/post-synth";
6
+ import { loadPolicyChecks } from "../../lint/policy";
7
7
  import { sortedJsonReplacer } from "../../utils";
8
8
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
9
9
  import { writeFileSync, mkdirSync } from "fs";
@@ -26,8 +26,15 @@ export interface BuildOptions {
26
26
  plugins?: LexiconPlugin[];
27
27
  /** Print summary to stderr */
28
28
  verbose?: boolean;
29
+ /**
30
+ * Environment/stack to evaluate policy against (`--env`). Falls back to the
31
+ * project's `ownership.env`. Passed into post-synth checks so organizational
32
+ * policy can branch on environment.
33
+ */
34
+ env?: string;
29
35
  }
30
36
 
37
+
31
38
  /**
32
39
  * Build command result
33
40
  */
@@ -56,11 +63,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
56
63
 
57
64
  // Resolve opt-in ownership marking from project config (search the infra dir
58
65
  // and its parent, where chant.config.* usually lives).
59
- const { config } = await loadChantConfig(infraPath).then((r) =>
66
+ const loaded = await loadChantConfig(infraPath).then((r) =>
60
67
  r.configPath ? r : loadChantConfig(dirname(infraPath)),
61
68
  );
69
+ const config = loaded.config;
62
70
  const ownership = resolveOwnershipMarker(config);
63
71
 
72
+ // Environment for policy evaluation: explicit --env wins, else ownership.env.
73
+ const env = options.env ?? config.ownership?.env;
74
+ // Project-authored organizational policy checks (lint.policies), run over the
75
+ // resolved resources during build. Resolve paths relative to the config dir.
76
+ const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
77
+ const policyChecks = config.lint?.policies?.length
78
+ ? await loadPolicyChecks(config.lint.policies, configDir)
79
+ : [];
80
+
64
81
  // Run the build
65
82
  const result = await build(infraPath, options.serializers, undefined, { ownership });
66
83
 
@@ -96,7 +113,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
96
113
  }
97
114
 
98
115
  const scopedResult = { ...result, outputs: scopedOutputs };
99
- const postDiags = runPostSynthChecks(checks, scopedResult);
116
+ const postDiags = runPostSynthChecks(checks, scopedResult, env);
100
117
  for (const diag of postDiags) {
101
118
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
102
119
  const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
@@ -107,6 +124,19 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
107
124
  }
108
125
  }
109
126
  }
127
+
128
+ // Project-authored organizational policy — cross-cutting, so it sees every
129
+ // lexicon's output at once (not scoped per-plugin), with the current env.
130
+ if (policyChecks.length > 0) {
131
+ const policyDiags = runPostSynthChecks(policyChecks, result, env);
132
+ for (const diag of policyDiags) {
133
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
134
+ const where = diag.lexicon ? ` (${diag.lexicon})` : "";
135
+ const msg = `[policy:${diag.checkId}] ${prefix}${diag.message}${where}`;
136
+ if (diag.severity === "error") errors.push(formatError({ message: msg }));
137
+ else warnings.push(formatWarning({ message: msg }));
138
+ }
139
+ }
110
140
  }
111
141
 
112
142
  // Empty-output guard: source files were discovered but no lexicon produced
@@ -44,6 +44,7 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
44
44
  serializers,
45
45
  plugins,
46
46
  verbose: args.verbose,
47
+ env: args.env,
47
48
  });
48
49
 
49
50
  // When --lexicon filters to a subset, suppress "No serializer" warnings for excluded lexicons
@@ -1,8 +1,21 @@
1
+ import { resolve } from "node:path";
1
2
  import { discoverOps } from "../../op/discover";
2
- import { formatError } from "../format";
3
+ import { discover } from "../../discovery/index";
4
+ import { partitionByLexicon, computeStackGraph } from "../../build";
5
+ import { formatError, formatWarning, formatBold } from "../format";
3
6
  import type { CommandContext } from "../registry";
4
7
 
5
- export async function runGraph(_ctx: CommandContext): Promise<number> {
8
+ /**
9
+ * `chant graph` — the Op dependency graph by default; `--stacks` renders the
10
+ * cross-stack apply-ordering graph (edges, order, waves) chant computes from
11
+ * cross-lexicon references.
12
+ */
13
+ export async function runGraph(ctx: CommandContext): Promise<number> {
14
+ if (ctx.args.stacks) return runStackGraph(ctx);
15
+ return runOpGraph();
16
+ }
17
+
18
+ async function runOpGraph(): Promise<number> {
6
19
  const { ops, errors } = await discoverOps();
7
20
  for (const err of errors) console.error(formatError({ message: err }));
8
21
 
@@ -21,3 +34,43 @@ export async function runGraph(_ctx: CommandContext): Promise<number> {
21
34
  if (!hasEdges) console.log("No Op dependencies");
22
35
  return 0;
23
36
  }
37
+
38
+ async function runStackGraph(ctx: CommandContext): Promise<number> {
39
+ const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
40
+ const result = await discover(projectPath);
41
+ if (result.errors.length > 0) {
42
+ for (const e of result.errors) console.error(formatError({ message: e.message }));
43
+ return 1;
44
+ }
45
+
46
+ const lexicons = [...partitionByLexicon(result.entities).keys()];
47
+ const graph = computeStackGraph(result.entities, lexicons);
48
+
49
+ if (ctx.args.json) {
50
+ console.log(JSON.stringify(graph, null, 2));
51
+ return graph.cycles.length > 0 ? 1 : 0;
52
+ }
53
+
54
+ if (graph.nodes.length === 0) {
55
+ console.log("No stacks found");
56
+ return 0;
57
+ }
58
+
59
+ console.log(formatBold("Apply order (waves apply top-to-bottom; a wave's stacks are parallel-safe):"));
60
+ graph.waves.forEach((wave, i) => console.log(` ${i + 1}. ${wave.join(", ")}`));
61
+
62
+ if (graph.edges.length > 0) {
63
+ console.log(formatBold("\nDependencies (consumer → producer):"));
64
+ for (const { from, to } of graph.edges) console.log(` ${from} → ${to}`);
65
+ } else {
66
+ console.log("\nNo cross-stack dependencies — all stacks are independent.");
67
+ }
68
+
69
+ if (graph.cycles.length > 0) {
70
+ for (const cycle of graph.cycles) {
71
+ console.error(formatWarning({ message: `Dependency cycle among stacks: ${cycle.join(" ↔ ")}` }));
72
+ }
73
+ return 1;
74
+ }
75
+ return 0;
76
+ }
@@ -5,6 +5,7 @@ import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchLifecycle,
5
5
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
6
6
  import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../lifecycle/live-diff";
7
7
  import { buildChangeSet, renderChangeSet, type ChangeSet } from "../../lifecycle/change-set";
8
+ import { affectedStacks } from "../../lifecycle/affected";
8
9
  import { loadChantConfig } from "../../config";
9
10
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
10
11
  import type { CommandContext } from "../registry";
@@ -576,3 +577,59 @@ function printSnapshotTable(snapshot: LifecycleSnapshot): void {
576
577
  );
577
578
  }
578
579
  }
580
+
581
+ /**
582
+ * chant lifecycle affected --base <ref> [--head <ref>] [--include-dependents] [--json]
583
+ *
584
+ * Read-only: report which stacks a change affects (directly-changed via artifact
585
+ * diff, dependents via the cross-stack graph, external-input as indeterminate).
586
+ * Returns the set; fanning plan/apply over it is an Op the user composes.
587
+ */
588
+ export async function runLifecycleAffected(ctx: CommandContext): Promise<number> {
589
+ const { args, plugins } = ctx;
590
+ if (!args.base) {
591
+ console.error(formatError({
592
+ message: "Base ref is required: chant lifecycle affected --base <ref> [--head <ref>] [--include-dependents]",
593
+ }));
594
+ return 1;
595
+ }
596
+
597
+ const { config } = await loadChantConfig(resolve("."));
598
+ const projectPath = resolveBuildRoot(args, config);
599
+
600
+ let result;
601
+ try {
602
+ result = await affectedStacks({
603
+ projectPath,
604
+ serializers: plugins.map((p) => p.serializer),
605
+ baseRef: args.base,
606
+ headRef: args.head,
607
+ includeDependents: args.includeDependents,
608
+ });
609
+ } catch (err) {
610
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
611
+ return 1;
612
+ }
613
+
614
+ if (args.json) {
615
+ console.log(JSON.stringify(result, null, 2));
616
+ return 0;
617
+ }
618
+
619
+ if (result.changed.length === 0 && result.dependents.length === 0) {
620
+ console.error(formatSuccess("No stacks affected"));
621
+ } else {
622
+ console.log(formatBold("Directly changed:"));
623
+ console.log(result.changed.length ? result.changed.map((s) => ` ${s}`).join("\n") : " (none)");
624
+ if (args.includeDependents) {
625
+ console.log(formatBold("\nDependents (consume a changed stack):"));
626
+ console.log(result.dependents.length ? result.dependents.map((s) => ` ${s}`).join("\n") : " (none)");
627
+ }
628
+ }
629
+ if (result.indeterminate.length > 0) {
630
+ console.error(formatWarning({
631
+ message: `External-input stacks — cannot confirm from source: ${result.indeterminate.join(", ")}`,
632
+ }));
633
+ }
634
+ return 0;
635
+ }
package/src/cli/main.ts CHANGED
@@ -14,7 +14,7 @@ import { runInit, runInitLexicon } from "./handlers/init";
14
14
  import { runList, runDescribe, runImport, runUpdate, runDoctor } from "./handlers/misc";
15
15
  import { runVendor } from "./handlers/vendor";
16
16
  import { runMigrate } from "./handlers/migrate";
17
- import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
17
+ import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
18
18
  import { runGraph } from "./handlers/graph";
19
19
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
20
20
 
@@ -51,6 +51,7 @@ export function parseArgs(args: string[]): ParsedArgs {
51
51
  reportFile: undefined,
52
52
  skill: undefined,
53
53
  src: undefined,
54
+ env: undefined,
54
55
  };
55
56
 
56
57
  let i = 0;
@@ -115,6 +116,16 @@ export function parseArgs(args: string[]): ParsedArgs {
115
116
  result.skill = args[++i];
116
117
  } else if (arg === "--src") {
117
118
  result.src = args[++i];
119
+ } else if (arg === "--env") {
120
+ result.env = args[++i];
121
+ } else if (arg === "--stacks") {
122
+ result.stacks = true;
123
+ } else if (arg === "--base") {
124
+ result.base = args[++i];
125
+ } else if (arg === "--head") {
126
+ result.head = args[++i];
127
+ } else if (arg === "--include-dependents") {
128
+ result.includeDependents = true;
118
129
  } else if (arg === "--local") {
119
130
  result.local = true;
120
131
  } else if (arg === "--temporal") {
@@ -169,7 +180,7 @@ Ops:
169
180
  run cancel <name> Cancel the active workflow run (requires --force)
170
181
  run log <name> Show run history for an Op
171
182
 
172
- graph Show Op dependency graph
183
+ graph Show Op dependency graph (--stacks for cross-stack order)
173
184
 
174
185
  Lifecycle (alias: lc):
175
186
  lifecycle snapshot <env> Query API, save metadata to orphan branch
@@ -177,6 +188,7 @@ Lifecycle (alias: lc):
177
188
  lifecycle diff <env> Compare current build against last snapshot
178
189
  --live: query cloud now and detect drift
179
190
  lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
191
+ lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
180
192
  --json: emit the ChangeSet as JSON
181
193
  lifecycle log [env] History of lifecycle snapshots
182
194
 
@@ -201,6 +213,7 @@ Options:
201
213
  - list: text (default) or json
202
214
  - lint: stylish (default), json, or sarif
203
215
  -d, --lexicon <name> Build only the specified lexicon (e.g. aws, gitlab)
216
+ --env <name> Environment for organizational policy evaluation (build)
204
217
  -t, --template <name> Init template (e.g. node-pipeline, docker-build)
205
218
  --skill <name> Init: install only this skill from the lexicon
206
219
  --fix Auto-fix fixable issues (lint command)
@@ -300,6 +313,7 @@ const registry: CommandDef[] = [
300
313
  { name: "lifecycle show", handler: runLifecycleShow },
301
314
  { name: "lifecycle diff", requiresPlugins: true, handler: runLifecycleDiff },
302
315
  { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
316
+ { name: "lifecycle affected", requiresPlugins: true, handler: runLifecycleAffected },
303
317
  { name: "lifecycle log", handler: runLifecycleLog },
304
318
 
305
319
  // Serve subcommands
@@ -53,6 +53,16 @@ export interface ParsedArgs {
53
53
  verbatim?: boolean;
54
54
  /** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
55
55
  src?: string;
56
+ /** `chant build --env <name>` — environment for organizational policy evaluation */
57
+ env?: string;
58
+ /** `chant graph --stacks` — render the cross-stack apply-ordering graph */
59
+ stacks?: boolean;
60
+ /** `chant lifecycle affected --base <ref>` — base git ref to diff against */
61
+ base?: string;
62
+ /** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
63
+ head?: string;
64
+ /** `chant lifecycle affected --include-dependents` — add downstream consumers */
65
+ includeDependents?: boolean;
56
66
  }
57
67
 
58
68
  /**
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export * from "./lint/declarative";
32
32
  export * from "./lint/selectors";
33
33
  export * from "./lint/named-checks";
34
34
  export * from "./lint/post-synth";
35
+ export * from "./lint/policy";
35
36
  export * from "./lint/rule-loader";
36
37
  export * from "./lint/discover";
37
38
  export * from "./import/parser";
@@ -0,0 +1,161 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import {
8
+ changedStacks,
9
+ dependentStacks,
10
+ computeAffected,
11
+ externalInputStacks,
12
+ affectedStacks,
13
+ } from "./affected";
14
+ import type { StackGraph } from "../build";
15
+ import type { Serializer } from "../serializer";
16
+ import type { Declarable } from "../declarable";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ // Serializer whose output reflects entity names + types, so a value change moves
21
+ // the bytes but an unrelated edit (comment) does not.
22
+ const fakeSerializer: Serializer = {
23
+ name: "fake",
24
+ rulePrefix: "FAKE",
25
+ serialize: (entities) =>
26
+ JSON.stringify([...entities.keys()].sort().map((k) => ({ k, t: entities.get(k)!.entityType }))),
27
+ };
28
+
29
+ function widget(type: string): string {
30
+ return `export const foo = { lexicon: "fake", entityType: "${type}", [Symbol.for("chant.declarable")]: true };\n`;
31
+ }
32
+
33
+ // ── Pure model ────────────────────────────────────────────────────────────────
34
+
35
+ describe("changedStacks", () => {
36
+ test("flags stacks whose output differs, and added/removed stacks", () => {
37
+ const base = new Map([["a", "1"], ["b", "2"], ["gone", "x"]]);
38
+ const head = new Map([["a", "1"], ["b", "CHANGED"], ["new", "y"]]);
39
+ expect(changedStacks(base, head)).toEqual(["b", "gone", "new"]);
40
+ });
41
+ test("identical builds → nothing changed", () => {
42
+ const m = new Map([["a", "1"], ["b", "2"]]);
43
+ expect(changedStacks(m, new Map(m))).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe("dependentStacks", () => {
48
+ // Diamond: top→left, top→right, left→base, right→base (consumer→producer).
49
+ const graph: StackGraph = {
50
+ nodes: ["base", "left", "right", "top"],
51
+ edges: [
52
+ { from: "left", to: "base" },
53
+ { from: "right", to: "base" },
54
+ { from: "top", to: "left" },
55
+ { from: "top", to: "right" },
56
+ ],
57
+ order: ["base", "left", "right", "top"],
58
+ waves: [["base"], ["left", "right"], ["top"]],
59
+ cycles: [],
60
+ };
61
+ test("a changed producer surfaces all transitive consumers", () => {
62
+ expect(dependentStacks(["base"], graph)).toEqual(["left", "right", "top"]);
63
+ });
64
+ test("a changed mid-stack surfaces only what's above it", () => {
65
+ expect(dependentStacks(["left"], graph)).toEqual(["top"]);
66
+ });
67
+ test("nothing depends on the top", () => {
68
+ expect(dependentStacks(["top"], graph)).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("computeAffected", () => {
73
+ const graph: StackGraph = {
74
+ nodes: ["aws", "k8s"],
75
+ edges: [{ from: "k8s", to: "aws" }],
76
+ order: ["aws", "k8s"],
77
+ waves: [["aws"], ["k8s"]],
78
+ cycles: [],
79
+ };
80
+ const base = new Map([["aws", "v1"], ["k8s", "same"]]);
81
+ const head = new Map([["aws", "v2"], ["k8s", "same"]]);
82
+
83
+ test("dependents excluded by default", () => {
84
+ const r = computeAffected(base, head, graph);
85
+ expect(r.changed).toEqual(["aws"]);
86
+ expect(r.dependents).toEqual([]);
87
+ });
88
+ test("--include-dependents adds the unchanged consumer of a changed producer", () => {
89
+ const r = computeAffected(base, head, graph, { includeDependents: true });
90
+ expect(r.changed).toEqual(["aws"]);
91
+ expect(r.dependents).toEqual(["k8s"]); // k8s bytes unchanged, but consumes aws
92
+ });
93
+ test("external-input stacks are reported as indeterminate", () => {
94
+ const r = computeAffected(base, head, graph, { externalInput: ["k8s"] });
95
+ expect(r.indeterminate).toEqual(["k8s"]);
96
+ });
97
+ });
98
+
99
+ describe("externalInputStacks", () => {
100
+ test("detects stacks declaring a deploy-time parameter", () => {
101
+ const entities = new Map<string, Declarable>([
102
+ ["p", { lexicon: "aws", entityType: "Param", parameterType: "String" } as unknown as Declarable],
103
+ ["r", { lexicon: "k8s", entityType: "Deployment" } as unknown as Declarable],
104
+ ]);
105
+ expect(externalInputStacks(entities)).toEqual(["aws"]);
106
+ });
107
+ });
108
+
109
+ // ── Integration: artifact diff over real builds ──────────────────────────────
110
+
111
+ describe("affectedStacks — baseDir (caller-supplied)", () => {
112
+ let root: string;
113
+ beforeEach(() => {
114
+ root = mkdtempSync(join(tmpdir(), "chant-affected-it-"));
115
+ });
116
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
117
+
118
+ const srcDir = (name: string, content: string): string => {
119
+ const dir = join(root, name);
120
+ mkdirSync(dir, { recursive: true });
121
+ writeFileSync(join(dir, "infra.ts"), content);
122
+ return dir;
123
+ };
124
+
125
+ test("a value change makes the stack affected", async () => {
126
+ const base = srcDir("base", widget("Widget"));
127
+ const head = srcDir("head", widget("Gadget"));
128
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer] });
129
+ expect(r.changed).toEqual(["fake"]);
130
+ });
131
+
132
+ test("a no-output-change refactor is NOT affected (deterministic build)", async () => {
133
+ const base = srcDir("base", widget("Widget"));
134
+ // Same declarable, only a comment added — serialized output is identical.
135
+ const head = srcDir("head", "// a harmless refactor\n" + widget("Widget"));
136
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer] });
137
+ expect(r.changed).toEqual([]);
138
+ });
139
+ });
140
+
141
+ describe("affectedStacks — baseRef (git worktree)", () => {
142
+ let repo: string;
143
+ beforeEach(async () => {
144
+ repo = mkdtempSync(join(tmpdir(), "chant-affected-git-"));
145
+ const git = (...a: string[]) => execFileAsync("git", a, { cwd: repo });
146
+ await git("init", "-q");
147
+ await git("config", "user.email", "t@t.dev");
148
+ await git("config", "user.name", "t");
149
+ writeFileSync(join(repo, "infra.ts"), widget("Widget"));
150
+ await git("add", "-A");
151
+ await git("commit", "-q", "-m", "base");
152
+ });
153
+ afterEach(() => rmSync(repo, { recursive: true, force: true }));
154
+
155
+ test("diffs the working tree against a base ref via one worktree", async () => {
156
+ // Mutate the working tree (uncommitted) — the head build sees this.
157
+ writeFileSync(join(repo, "infra.ts"), widget("Gadget"));
158
+ const r = await affectedStacks({ projectPath: repo, baseRef: "HEAD", serializers: [fakeSerializer] });
159
+ expect(r.changed).toEqual(["fake"]);
160
+ }, 30_000);
161
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Selective change-set scoping — which stacks does a change affect?
3
+ *
4
+ * "Affected" is two distinct things, because chant serializes cross-stack
5
+ * references symbolically:
6
+ * 1. **Directly changed** — the stack's built artifact differs between base
7
+ * and head. Caught by artifact diff over deterministic builds, so it
8
+ * ignores comment-only / refactor-with-no-output-change edits.
9
+ * 2. **Operationally affected (dependents)** — a stack whose own artifact is
10
+ * unchanged but which consumes an upstream export whose value changed. Its
11
+ * bytes don't move, yet it may need re-apply. Caught by walking the
12
+ * cross-stack graph (#200) from the directly-changed set; opt-in.
13
+ *
14
+ * A stack whose inputs come from outside synthesis (deploy-time params) cannot
15
+ * be judged from a source diff — reported as **indeterminate**, not silently
16
+ * included or excluded.
17
+ *
18
+ * This returns the set; it does not act. Fanning `lifecycle plan` / `ApplyOp`
19
+ * over it is an Op the user composes.
20
+ */
21
+ import { execFile } from "node:child_process";
22
+ import { promisify } from "node:util";
23
+ import { mkdtempSync, rmSync, existsSync, symlinkSync, realpathSync } from "node:fs";
24
+ import { join, relative, resolve } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+ import { build, type StackGraph } from "../build";
27
+ import { getPrimaryOutput } from "../lint/post-synth";
28
+ import type { Serializer } from "../serializer";
29
+ import type { Declarable } from "../declarable";
30
+
31
+ const execFileAsync = promisify(execFile);
32
+
33
+ export interface AffectedResult {
34
+ /** Stacks whose built artifact differs between base and head. */
35
+ changed: string[];
36
+ /** Downstream consumers of changed stacks (only when `includeDependents`). */
37
+ dependents: string[];
38
+ /** Stacks with deploy-time inputs — cannot be confirmed from a source diff. */
39
+ indeterminate: string[];
40
+ }
41
+
42
+ // ── Pure model ──────────────────────────────────────────────────────────────
43
+
44
+ /** Stacks whose serialized output differs between the two builds (added/removed count). */
45
+ export function changedStacks(
46
+ base: Map<string, string>,
47
+ head: Map<string, string>,
48
+ ): string[] {
49
+ const names = new Set([...base.keys(), ...head.keys()]);
50
+ const changed: string[] = [];
51
+ for (const name of names) {
52
+ if (base.get(name) !== head.get(name)) changed.push(name);
53
+ }
54
+ return changed.sort();
55
+ }
56
+
57
+ /**
58
+ * Walk the cross-stack graph backwards from `changed` to find every stack that
59
+ * (transitively) consumes a changed stack. Edge `from → to` means `from`
60
+ * depends on `to`, so a changed `to` makes `from` a dependent.
61
+ */
62
+ export function dependentStacks(changed: string[], graph: StackGraph): string[] {
63
+ const consumersOf = new Map<string, string[]>();
64
+ for (const { from, to } of graph.edges) {
65
+ (consumersOf.get(to) ?? consumersOf.set(to, []).get(to)!).push(from);
66
+ }
67
+ const seen = new Set(changed);
68
+ const queue = [...changed];
69
+ const dependents = new Set<string>();
70
+ while (queue.length > 0) {
71
+ const node = queue.shift()!;
72
+ for (const consumer of consumersOf.get(node) ?? []) {
73
+ if (seen.has(consumer)) continue;
74
+ seen.add(consumer);
75
+ dependents.add(consumer);
76
+ queue.push(consumer);
77
+ }
78
+ }
79
+ return [...dependents].sort();
80
+ }
81
+
82
+ /**
83
+ * Compute the affected set from two per-stack artifact maps and the cross-stack
84
+ * graph. Pure — no builds, no git — so the model is independently testable.
85
+ */
86
+ export function computeAffected(
87
+ base: Map<string, string>,
88
+ head: Map<string, string>,
89
+ graph: StackGraph,
90
+ opts: { includeDependents?: boolean; externalInput?: string[] } = {},
91
+ ): AffectedResult {
92
+ const changed = changedStacks(base, head);
93
+ const dependents = opts.includeDependents ? dependentStacks(changed, graph) : [];
94
+ const indeterminate = [...(opts.externalInput ?? [])].sort();
95
+ return { changed, dependents, indeterminate };
96
+ }
97
+
98
+ // ── Build + git plumbing ──────────────────────────────────────────────────────
99
+
100
+ /** A built BuildResult's per-stack (lexicon) primary output, keyed by stack. */
101
+ export function artifactMap(outputs: Map<string, string | { primary: string }>): Map<string, string> {
102
+ const map = new Map<string, string>();
103
+ for (const [stack, output] of outputs) map.set(stack, getPrimaryOutput(output as string));
104
+ return map;
105
+ }
106
+
107
+ /** Stacks (lexicons) that declare a deploy-time parameter — external input. */
108
+ export function externalInputStacks(entities: Map<string, Declarable>): string[] {
109
+ const stacks = new Set<string>();
110
+ for (const [, entity] of entities) {
111
+ if ("parameterType" in entity && typeof (entity as { parameterType?: unknown }).parameterType === "string") {
112
+ stacks.add(entity.lexicon);
113
+ }
114
+ }
115
+ return [...stacks].sort();
116
+ }
117
+
118
+ async function gitTopLevel(cwd: string): Promise<string> {
119
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
120
+ return stdout.trim();
121
+ }
122
+
123
+ /**
124
+ * Check out `ref` into a throwaway worktree, symlink the repo's node_modules so
125
+ * lexicon imports resolve, run `fn`, then remove the worktree. At most one
126
+ * worktree exists at a time.
127
+ */
128
+ async function withWorktree<T>(repoRoot: string, ref: string, fn: (dir: string) => Promise<T>): Promise<T> {
129
+ const dir = mkdtempSync(join(tmpdir(), "chant-affected-"));
130
+ await execFileAsync("git", ["worktree", "add", "--detach", dir, ref], { cwd: repoRoot });
131
+ try {
132
+ const nm = join(repoRoot, "node_modules");
133
+ const linked = join(dir, "node_modules");
134
+ if (existsSync(nm) && !existsSync(linked)) symlinkSync(nm, linked, "dir");
135
+ return await fn(dir);
136
+ } finally {
137
+ await execFileAsync("git", ["worktree", "remove", "--force", dir], { cwd: repoRoot }).catch(() => {
138
+ rmSync(dir, { recursive: true, force: true });
139
+ });
140
+ }
141
+ }
142
+
143
+ export interface AffectedStacksOptions {
144
+ /** Project source directory to scope (the head/working-tree build root). */
145
+ projectPath: string;
146
+ serializers: Serializer[];
147
+ /** Base git ref to diff against (built in a throwaway worktree). */
148
+ baseRef?: string;
149
+ /** Head git ref. Defaults to the working tree (built in place — no worktree). */
150
+ headRef?: string;
151
+ /**
152
+ * Caller-supplied base source directory (already on disk). Takes precedence
153
+ * over `baseRef` — no worktree is created. Use for a build cache or two dist
154
+ * trees you already have.
155
+ */
156
+ baseDir?: string;
157
+ includeDependents?: boolean;
158
+ }
159
+
160
+ /**
161
+ * Compute the affected stacks for a change. Head is built in place (the working
162
+ * tree); base is built from `baseDir` if supplied, else from `baseRef` via a
163
+ * single throwaway worktree. Builds are deterministic, so a cached/supplied base
164
+ * is as trustworthy as a rebuild.
165
+ */
166
+ export async function affectedStacks(opts: AffectedStacksOptions): Promise<AffectedResult> {
167
+ const projectPath = resolve(opts.projectPath);
168
+ const needsWorktree = Boolean(opts.headRef) || Boolean(opts.baseRef && !opts.baseDir);
169
+ const repoRoot = needsWorktree ? await gitTopLevel(projectPath) : projectPath;
170
+ // Canonicalize via realpath so the worktree-relative path is correct even when
171
+ // the temp dir is a symlink (macOS /var → /private/var) and `git
172
+ // rev-parse --show-toplevel` reports the real path.
173
+ const relProject = needsWorktree ? relative(repoRoot, realpathSync(projectPath)) : "";
174
+
175
+ // Head: the in-place build of the working tree, unless an explicit headRef is
176
+ // given (then a throwaway worktree — removed before the base worktree, so at
177
+ // most one exists at a time).
178
+ const head = opts.headRef
179
+ ? await withWorktree(repoRoot, opts.headRef, (dir) => build(join(dir, relProject), opts.serializers))
180
+ : await build(projectPath, opts.serializers);
181
+ const headMap = artifactMap(head.outputs);
182
+ const externalInput = externalInputStacks(head.entities);
183
+
184
+ // Base: caller-supplied dir (cheapest), else a single worktree at baseRef.
185
+ let baseMap: Map<string, string>;
186
+ if (opts.baseDir) {
187
+ const baseBuild = await build(resolve(opts.baseDir), opts.serializers);
188
+ baseMap = artifactMap(baseBuild.outputs);
189
+ } else if (opts.baseRef) {
190
+ baseMap = await withWorktree(repoRoot, opts.baseRef, async (dir) => {
191
+ const baseBuild = await build(join(dir, relProject), opts.serializers);
192
+ return artifactMap(baseBuild.outputs);
193
+ });
194
+ } else {
195
+ throw new Error("affectedStacks requires either baseDir or baseRef");
196
+ }
197
+
198
+ return computeAffected(baseMap, headMap, head.manifest.stackGraph, {
199
+ includeDependents: opts.includeDependents,
200
+ externalInput,
201
+ });
202
+ }
@@ -4,3 +4,4 @@ export * from "./digest";
4
4
  export * from "./snapshot";
5
5
  export * from "./live-diff";
6
6
  export * from "./change-set";
7
+ export * from "./affected";
@@ -29,6 +29,7 @@ export const LintConfigSchema = z.object({
29
29
  rules: z.record(z.string(), RuleConfigSchema),
30
30
  })).optional(),
31
31
  plugins: z.array(z.string()).optional(),
32
+ policies: z.array(z.string()).optional(),
32
33
  });
33
34
 
34
35
  /**
@@ -149,6 +150,14 @@ export interface LintConfig {
149
150
  overrides?: LintOverride[];
150
151
  /** Array of plugin file paths to load custom rules from (project-local, not inherited) */
151
152
  plugins?: string[];
153
+ /**
154
+ * Array of file paths to load project-authored organizational policy checks
155
+ * from — each exporting one or more {@link PostSynthCheck} objects. They run
156
+ * during `chant build` over the resolved resources, with the current `env` in
157
+ * context. Distinct from `plugins` (declarative lint rules) by authorship and
158
+ * phase; same engine.
159
+ */
160
+ policies?: string[];
152
161
  }
153
162
 
154
163
  /**
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Project-authored organizational policy: loading the policy pack and evaluating
3
+ * it against a freshly built project. `chant build` runs policies inline; the
4
+ * `policyGate` Op step runs this to gate an apply on the same checks.
5
+ */
6
+ import { resolve, dirname } from "node:path";
7
+ import { loadChantConfig } from "../config";
8
+ import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
9
+ import { build } from "../build";
10
+ import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
11
+ import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
12
+
13
+ /** Load project policy checks (one or more `PostSynthCheck` exports) from files. */
14
+ export async function loadPolicyChecks(paths: string[], configDir: string): Promise<PostSynthCheck[]> {
15
+ const checks: PostSynthCheck[] = [];
16
+ for (const p of paths) {
17
+ const resolved = resolve(configDir, p);
18
+ let mod: Record<string, unknown>;
19
+ try {
20
+ mod = (await import(resolved)) as Record<string, unknown>;
21
+ } catch (err) {
22
+ throw new Error(
23
+ `Failed to load policy "${p}": ${err instanceof Error ? err.message : String(err)}`,
24
+ );
25
+ }
26
+ for (const value of Object.values(mod)) {
27
+ if (isPostSynthCheck(value)) checks.push(value);
28
+ }
29
+ }
30
+ return checks;
31
+ }
32
+
33
+ export interface PolicyEvaluation {
34
+ /** All policy diagnostics (errors + warnings). */
35
+ diagnostics: PostSynthDiagnostic[];
36
+ /** The error-severity subset — these are policy *violations* that gate. */
37
+ violations: PostSynthDiagnostic[];
38
+ /** The environment policies were evaluated against (if any). */
39
+ env?: string;
40
+ }
41
+
42
+ /**
43
+ * Build a project and run its `lint.policies` over the resolved resources,
44
+ * standalone — used by the `policyGate` Op step to gate an apply on the same
45
+ * organizational policy `chant build` enforces. Loads the project's lexicons,
46
+ * builds, then runs the policy pack with `env` (explicit, else `ownership.env`).
47
+ */
48
+ export async function evaluateProjectPolicies(opts: {
49
+ path: string;
50
+ env?: string;
51
+ }): Promise<PolicyEvaluation> {
52
+ const buildPath = resolve(opts.path);
53
+
54
+ const lexiconNames = await resolveProjectLexicons(buildPath);
55
+ const plugins = await loadPlugins(lexiconNames);
56
+ const serializers = plugins.map((p) => p.serializer);
57
+
58
+ // Config can live in the build dir or its parent (the project root).
59
+ const loaded = await loadChantConfig(buildPath).then((r) =>
60
+ r.configPath ? r : loadChantConfig(dirname(buildPath)),
61
+ );
62
+ const config = loaded.config;
63
+ const configDir = loaded.configPath ? dirname(loaded.configPath) : buildPath;
64
+ const env = opts.env ?? config.ownership?.env;
65
+
66
+ const result = await build(buildPath, serializers);
67
+ if (result.errors.length > 0) {
68
+ throw new Error("Build failed — cannot evaluate policy on a broken build");
69
+ }
70
+
71
+ const checks = config.lint?.policies?.length
72
+ ? await loadPolicyChecks(config.lint.policies, configDir)
73
+ : [];
74
+ if (checks.length === 0) {
75
+ return { diagnostics: [], violations: [], env };
76
+ }
77
+
78
+ const diagnostics = runPostSynthChecks(checks, result, env);
79
+ const violations = diagnostics.filter((d) => d.severity === "error");
80
+ return { diagnostics, violations, env };
81
+ }
@@ -111,3 +111,33 @@ describe("post-synth checks", () => {
111
111
  expect(diags).toHaveLength(0);
112
112
  });
113
113
  });
114
+
115
+ describe("environment-aware checks (#201)", () => {
116
+ // A policy that only fires in prod — the new env-branching primitive.
117
+ const prodOnly: PostSynthCheck = {
118
+ id: "ENV-PROD",
119
+ description: "fires only in prod",
120
+ check(ctx) {
121
+ return ctx.env === "prod"
122
+ ? [{ checkId: "ENV-PROD", severity: "error", message: "blocked in prod" }]
123
+ : [];
124
+ },
125
+ };
126
+
127
+ test("env is threaded into the context", () => {
128
+ expect(runPostSynthChecks([prodOnly], createBuildResult(), "prod")).toHaveLength(1);
129
+ expect(runPostSynthChecks([prodOnly], createBuildResult(), "dev")).toHaveLength(0);
130
+ expect(runPostSynthChecks([prodOnly], createBuildResult())).toHaveLength(0); // env undefined
131
+ });
132
+ });
133
+
134
+ describe("isPostSynthCheck", () => {
135
+ test("accepts a well-formed check and rejects others", async () => {
136
+ const { isPostSynthCheck } = await import("./post-synth");
137
+ expect(isPostSynthCheck({ id: "X", description: "d", check: () => [] })).toBe(true);
138
+ expect(isPostSynthCheck({ id: "X", description: "d" })).toBe(false); // no check fn
139
+ expect(isPostSynthCheck({ check: () => [] })).toBe(false); // no id/description
140
+ expect(isPostSynthCheck(null)).toBe(false);
141
+ expect(isPostSynthCheck("nope")).toBe(false);
142
+ });
143
+ });
@@ -10,6 +10,12 @@ export interface PostSynthContext {
10
10
  outputs: Map<string, string | SerializerResult>;
11
11
  /** Map of entity name to Declarable entity */
12
12
  entities: Map<string, Declarable>;
13
+ /**
14
+ * The environment/stack being built, if known (from `--env` or the project's
15
+ * `ownership.env`). Lets an organizational policy branch on environment —
16
+ * e.g. "no public buckets in prod". Undefined when no environment is set.
17
+ */
18
+ env?: string;
13
19
  /** Raw build result object */
14
20
  buildResult: {
15
21
  outputs: Map<string, string | SerializerResult>;
@@ -44,7 +50,9 @@ export interface PostSynthDiagnostic {
44
50
  }
45
51
 
46
52
  /**
47
- * A post-synthesis check that validates build output.
53
+ * A post-synthesis check that validates build output. Lexicons ship these as
54
+ * domain rules; projects author them as organizational policy (see the
55
+ * `lint.policies` config and the Organizational Policy guide).
48
56
  */
49
57
  export interface PostSynthCheck {
50
58
  /** Unique identifier for this check */
@@ -55,16 +63,30 @@ export interface PostSynthCheck {
55
63
  check(ctx: PostSynthContext): PostSynthDiagnostic[];
56
64
  }
57
65
 
66
+ /** Structural type guard — used to collect project-authored policy checks. */
67
+ export function isPostSynthCheck(value: unknown): value is PostSynthCheck {
68
+ return (
69
+ typeof value === "object" &&
70
+ value !== null &&
71
+ typeof (value as PostSynthCheck).id === "string" &&
72
+ typeof (value as PostSynthCheck).description === "string" &&
73
+ typeof (value as PostSynthCheck).check === "function"
74
+ );
75
+ }
76
+
58
77
  /**
59
- * Run a set of post-synthesis checks against a build result.
78
+ * Run a set of post-synthesis checks against a build result. `env` is threaded
79
+ * into the context so a check can branch on the current environment/stack.
60
80
  */
61
81
  export function runPostSynthChecks(
62
82
  checks: PostSynthCheck[],
63
83
  buildResult: PostSynthContext["buildResult"],
84
+ env?: string,
64
85
  ): PostSynthDiagnostic[] {
65
86
  const ctx: PostSynthContext = {
66
87
  outputs: buildResult.outputs,
67
88
  entities: buildResult.entities,
89
+ env,
68
90
  buildResult,
69
91
  };
70
92
 
@@ -130,3 +130,17 @@ export const shell = (
130
130
  /** Run `chant teardown` in the given project directory. Uses `longInfra` profile. */
131
131
  export const teardown = (path: string): ActivityStep =>
132
132
  activity("chantTeardown", { path }, "longInfra");
133
+
134
+ /**
135
+ * Gate an apply on organizational policy: build the project and run its
136
+ * `lint.policies` over the resolved resources, blocking the workflow on any
137
+ * violation. Place it before the apply phase. `env` (or `ownership.env`) lets a
138
+ * policy branch on environment. Single-attempt (`policyCheck` profile) — a
139
+ * deterministic violation is not retried.
140
+ */
141
+ export const policyGate = (opts?: { env?: string; path?: string }): ActivityStep =>
142
+ activity(
143
+ "policyGate",
144
+ { path: opts?.path ?? ".", ...(opts?.env ? { env: opts.env } : {}) },
145
+ "policyCheck",
146
+ );
package/src/op/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack,
2
- gitlabPipeline, lifecycleSnapshot, shell, teardown } from "./builders";
2
+ gitlabPipeline, lifecycleSnapshot, shell, teardown, policyGate } from "./builders";
3
3
  export { OpResource } from "./resource";
4
4
  export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
5
5
  export { discoverOps } from "./discover";
package/src/op/op.test.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { Op, phase, activity, gate, build, kubectlApply, helmInstall,
7
- waitForStack, gitlabPipeline, lifecycleSnapshot, shell, teardown } from "./builders";
7
+ waitForStack, gitlabPipeline, lifecycleSnapshot, shell, teardown, policyGate } from "./builders";
8
8
  import { DECLARABLE_MARKER, type Declarable } from "../declarable";
9
9
 
10
10
  // ── Op() ──────────────────────────────────────────────────────────────────────
@@ -196,6 +196,20 @@ describe("pre-built shortcuts", () => {
196
196
  expect(a.args?.path).toBe("./project");
197
197
  expect(a.profile).toBe("longInfra");
198
198
  });
199
+
200
+ it("policyGate() produces a policyGate activity with the single-attempt policyCheck profile", () => {
201
+ const a = policyGate({ env: "prod" });
202
+ expect(a.fn).toBe("policyGate");
203
+ expect(a.args?.path).toBe("."); // defaults to the project dir
204
+ expect(a.args?.env).toBe("prod");
205
+ expect(a.profile).toBe("policyCheck");
206
+ });
207
+
208
+ it("policyGate() with no opts defaults path to '.' and omits env", () => {
209
+ const a = policyGate();
210
+ expect(a.args?.path).toBe(".");
211
+ expect("env" in (a.args ?? {})).toBe(false);
212
+ });
199
213
  });
200
214
 
201
215
  describe("profile routing (opts.profile sets the step profile, never leaks into args)", () => {
package/src/op/types.ts CHANGED
@@ -45,7 +45,7 @@ export interface ActivityStep {
45
45
  * Key from TEMPORAL_ACTIVITY_PROFILES controlling timeout + retry.
46
46
  * Default: "fastIdempotent"
47
47
  */
48
- profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate" | "argoSync";
48
+ profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate" | "argoSync" | "policyCheck";
49
49
  /**
50
50
  * Surface this activity's return value as a workflow search attribute.
51
51
  *