@intentius/chant 0.1.18 → 0.1.19
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 +1 -1
- package/src/build.test.ts +48 -1
- package/src/build.ts +113 -2
- package/src/cli/commands/build.ts +33 -3
- package/src/cli/handlers/build.ts +1 -0
- package/src/cli/handlers/graph.ts +55 -2
- package/src/cli/handlers/lifecycle.ts +57 -0
- package/src/cli/main.ts +16 -2
- package/src/cli/registry.ts +10 -0
- package/src/index.ts +1 -0
- package/src/lifecycle/affected.test.ts +161 -0
- package/src/lifecycle/affected.ts +202 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lint/config.ts +9 -0
- package/src/lint/policy.ts +81 -0
- package/src/lint/post-synth.test.ts +30 -0
- package/src/lint/post-synth.ts +24 -2
- package/src/op/builders.ts +14 -0
- package/src/op/index.ts +1 -1
- package/src/op/op.test.ts +15 -1
- package/src/op/types.ts +1 -1
package/package.json
CHANGED
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
|
|
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
|
|
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
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { discoverOps } from "../../op/discover";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
package/src/lint/config.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/lint/post-synth.ts
CHANGED
|
@@ -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
|
|
package/src/op/builders.ts
CHANGED
|
@@ -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
|
*
|