@intentius/chant 0.1.17 → 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/commands/vendor.test.ts +148 -0
- package/src/cli/commands/vendor.ts +263 -0
- 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/handlers/vendor.ts +61 -0
- package/src/cli/main.ts +19 -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
|
@@ -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
|
*
|