@intentius/chant 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * chant #2002 — the `policyGate` Op step must gate on the build `chant build`
3
+ * produces, not on a different one.
4
+ *
5
+ * `evaluateProjectPolicies` used to assemble `build()`'s options itself, two of
6
+ * them against `buildCommand`'s nine. It built with fold off (the path #1134
7
+ * retired as the default), without the project's config (so serializers lost
8
+ * their lexicon-scoped dialect settings) and without `buildRoots` (so
9
+ * config-declared roots contributed nothing) — a gate could pass a build `chant
10
+ * build` fails. Both callers now assemble through
11
+ * `../cli/build-options.ts`'s `resolveProjectBuildOptions`, and the first test
12
+ * below fails if either ever grows an option the other does not get.
13
+ *
14
+ * `build()` is wrapped rather than replaced: every assertion here is about a
15
+ * real build of a real fixture, and the wrapper only records what each caller
16
+ * asked for.
17
+ */
18
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
19
+ import { mkdir, rm, writeFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+ import type { BuildOptions, BuildResult } from "../build";
23
+
24
+ const recorder = vi.hoisted(() => ({
25
+ options: [] as (BuildOptions | undefined)[],
26
+ results: [] as BuildResult[],
27
+ }));
28
+
29
+ vi.mock("../build", async (importOriginal) => {
30
+ const actual = await importOriginal<typeof import("../build")>();
31
+ return {
32
+ ...actual,
33
+ build: async (...args: Parameters<typeof actual.build>) => {
34
+ recorder.options.push(args[3]);
35
+ const result = await actual.build(...args);
36
+ recorder.results.push(result);
37
+ return result;
38
+ },
39
+ };
40
+ });
41
+
42
+ const { evaluateProjectPolicies } = await import("./policy");
43
+ const { buildCommand } = await import("../cli/commands/build");
44
+ const { resolveProjectLexicons, loadPlugins } = await import("../cli/plugins");
45
+
46
+ /** Run `chant build` over a fixture the way `cli/main.ts` drives it. */
47
+ async function runChantBuild(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
48
+ const plugins = await loadPlugins(await resolveProjectLexicons(dir));
49
+ recorder.options.length = 0;
50
+ recorder.results.length = 0;
51
+ const outcome = await buildCommand({
52
+ path: dir,
53
+ format: "json",
54
+ output: join(dir, "out", "build.json"),
55
+ serializers: plugins.map((p) => p.serializer),
56
+ plugins,
57
+ });
58
+ expect(outcome.errors).toEqual([]);
59
+ return { options: recorder.options[0]!, result: recorder.results[0]! };
60
+ }
61
+
62
+ /** Run the `policyGate` step's entry point over the same fixture. */
63
+ async function runPolicyGate(dir: string): Promise<{ options: BuildOptions; result: BuildResult }> {
64
+ recorder.options.length = 0;
65
+ recorder.results.length = 0;
66
+ await evaluateProjectPolicies({ path: dir });
67
+ return { options: recorder.options[0]!, result: recorder.results[0]! };
68
+ }
69
+
70
+ /** Build-root contributors are freshly bound closures — compare their count. */
71
+ function comparable(options: BuildOptions): Record<string, unknown> {
72
+ return { ...options, buildRoots: options.buildRoots?.length ?? 0 };
73
+ }
74
+
75
+ const TRIVIAL_POLICY =
76
+ `export const check = {\n` +
77
+ ` id: "ORG-PARITY",\n` +
78
+ ` description: "records nothing; the build is what is under test",\n` +
79
+ ` check: () => [],\n` +
80
+ `};\n`;
81
+
82
+ describe("policyGate builds the same project chant build does (chant #2002)", () => {
83
+ let testDir: string;
84
+
85
+ beforeEach(async () => {
86
+ testDir = join(tmpdir(), `chant-policy-parity-${Date.now()}-${Math.random()}`);
87
+ await mkdir(join(testDir, "policies"), { recursive: true });
88
+ await writeFile(join(testDir, "policies", "org.ts"), TRIVIAL_POLICY);
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await rm(testDir, { recursive: true, force: true });
93
+ vi.restoreAllMocks();
94
+ });
95
+
96
+ /**
97
+ * A forgejo project: `forgejo.runnerLabels` is read by the serializer off
98
+ * `SerializeContext.config` (lexicons/forgejo/src/serializer.ts), so a build
99
+ * that drops `config` emits the default label instead of the project's.
100
+ */
101
+ async function writeForgejoProject(): Promise<void> {
102
+ await writeFile(
103
+ join(testDir, "chant.config.ts"),
104
+ `export default {\n` +
105
+ ` lexicons: ["forgejo"],\n` +
106
+ ` forgejo: { runnerLabels: { "ubuntu-latest": "self-hosted-arm64" } },\n` +
107
+ ` lint: { policies: ["policies/org.ts"] },\n` +
108
+ `};\n`,
109
+ );
110
+ await writeFile(
111
+ join(testDir, "ci.ts"),
112
+ `import { Workflow, Job, Step } from "@intentius/chant-lexicon-github";\n` +
113
+ `export const workflow = new Workflow({ name: "CI", on: { push: {} } });\n` +
114
+ `export const buildJob = new Job({\n` +
115
+ ` "runs-on": "ubuntu-latest",\n` +
116
+ ` steps: [new Step({ name: "Build", run: "npm run build" })],\n` +
117
+ `});\n`,
118
+ );
119
+ }
120
+
121
+ test("both callers hand build() the identical option set", async () => {
122
+ await writeForgejoProject();
123
+
124
+ const cli = await runChantBuild(testDir);
125
+ const gate = await runPolicyGate(testDir);
126
+
127
+ // The drift this issue is about is an option one caller passes and the
128
+ // other does not, so the key sets are asserted on their own — a new option
129
+ // added at one call site fails here even if its value happens to match.
130
+ expect(Object.keys(gate.options).sort()).toEqual(Object.keys(cli.options).sort());
131
+ expect(comparable(gate.options)).toEqual(comparable(cli.options));
132
+ expect(gate.options.fold).toBe(true);
133
+ });
134
+
135
+ test("the project's lexicon-scoped dialect settings reach the gate's serializers", async () => {
136
+ await writeForgejoProject();
137
+
138
+ const cli = await runChantBuild(testDir);
139
+ const gate = await runPolicyGate(testDir);
140
+
141
+ expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
142
+ const emitted = JSON.stringify([...gate.result.outputs.values()]);
143
+ // The project's own label, not `DEFAULT_RUNNER_LABELS`' "docker" — which is
144
+ // what the gate emitted while it built without `config`.
145
+ expect(emitted).toContain("self-hosted-arm64");
146
+ expect(emitted).not.toContain("docker");
147
+ });
148
+
149
+ test("entities from a config-declared build root are in the set the gate sees", async () => {
150
+ await writeFile(
151
+ join(testDir, "chant.config.ts"),
152
+ `export default { lexicons: ["cedar"], lint: { policies: ["policies/org.ts"] } };\n`,
153
+ );
154
+ // cedar's buildRoots hook contributes the project's schema as an entity
155
+ // (lexicons/cedar/src/schema-artifact.ts). Without `buildRoots` the gate
156
+ // never saw it, so a policy over it could not fail.
157
+ await writeFile(join(testDir, "schema.cedarschema"), `entity User;\n`);
158
+
159
+ const cli = await runChantBuild(testDir);
160
+ const gate = await runPolicyGate(testDir);
161
+
162
+ expect([...gate.result.entities.keys()].sort()).toEqual([...cli.result.entities.keys()].sort());
163
+ expect([...gate.result.entities.keys()]).toContain("cedarSchema");
164
+ expect([...gate.result.outputs.entries()]).toEqual([...cli.result.outputs.entries()]);
165
+ });
166
+
167
+ test("a foldable source module does not execute during the gate's build", async () => {
168
+ delete process.env.CHANT_2002_SOURCE_RAN;
169
+ await writeFile(
170
+ join(testDir, "chant.config.ts"),
171
+ `export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
172
+ );
173
+ await writeFile(
174
+ join(testDir, "ns.ts"),
175
+ // Module-level: runs if and only if the file is imported. Fold reads the
176
+ // constructor call statically and never imports the module.
177
+ `process.env.CHANT_2002_SOURCE_RAN = "1";\n` +
178
+ `import { Namespace } from "@intentius/chant-lexicon-k8s";\n` +
179
+ `export const ns = new Namespace({ metadata: { name: "demo" } });\n`,
180
+ );
181
+
182
+ const gate = await runPolicyGate(testDir);
183
+
184
+ expect(gate.options.fold).toBe(true);
185
+ expect(gate.result.foldDecisions.find((d) => d.file.endsWith("ns.ts"))?.mode).toBe("fold");
186
+ expect(process.env.CHANT_2002_SOURCE_RAN).toBeUndefined();
187
+ });
188
+
189
+ test("build.fold: false is honoured too — the config decides, not the gate", async () => {
190
+ delete process.env.CHANT_2002_SOURCE_RAN;
191
+ await writeFile(
192
+ join(testDir, "chant.config.ts"),
193
+ `export default { lexicons: ["k8s"], build: { fold: false }, lint: { policies: ["policies/org.ts"] } };\n`,
194
+ );
195
+ // No lexicon import: with fold off every discovered file is imported, and
196
+ // this fixture lives outside the repo where a bare `@intentius/*`
197
+ // specifier does not resolve. The marker is all this test needs.
198
+ await writeFile(join(testDir, "marker.ts"), `process.env.CHANT_2002_SOURCE_RAN = "1";\nexport const value = 1;\n`);
199
+
200
+ const gate = await runPolicyGate(testDir);
201
+
202
+ expect(gate.options.fold).toBe(false);
203
+ expect(process.env.CHANT_2002_SOURCE_RAN).toBe("1");
204
+ delete process.env.CHANT_2002_SOURCE_RAN;
205
+ });
206
+
207
+ test("build.sandbox: true is reported as a divergence rather than silently ignored", async () => {
208
+ await writeFile(
209
+ join(testDir, "chant.config.ts"),
210
+ `export default { lexicons: ["k8s"], build: { sandbox: true }, lint: { policies: ["policies/org.ts"] } };\n`,
211
+ );
212
+ const stderr: string[] = [];
213
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderr.push(s); });
214
+
215
+ const evaluation = await evaluateProjectPolicies({ path: testDir });
216
+
217
+ expect(evaluation.warnings).toHaveLength(1);
218
+ expect(evaluation.warnings[0]).toContain("build.sandbox is enabled");
219
+ expect(stderr.join("\n")).toContain("build.sandbox is enabled");
220
+ });
221
+
222
+ test("no sandbox opt-in, no warning", async () => {
223
+ await writeFile(
224
+ join(testDir, "chant.config.ts"),
225
+ `export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"] } };\n`,
226
+ );
227
+
228
+ const evaluation = await evaluateProjectPolicies({ path: testDir });
229
+
230
+ expect(evaluation.warnings).toEqual([]);
231
+ });
232
+ });
@@ -7,6 +7,8 @@ import { resolve, dirname } from "node:path";
7
7
  import { loadChantConfigUpward, resolveOwnershipEnv, resolveOwnershipMarker } from "../config";
8
8
  import { resolveBuildParams } from "../build-params";
9
9
  import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
10
+ import { resolveBuildModes, resolveProjectBuildOptions } from "../cli/build-options";
11
+ import { formatWarning } from "../cli/format";
10
12
  import { build } from "../build";
11
13
  import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
12
14
  import type { PostSynthCheck, PostSynthDiagnostic } from "./post-synth";
@@ -63,6 +65,15 @@ export interface PolicyEvaluation {
63
65
  * of the finding just vanishing.
64
66
  */
65
67
  suppressed: PostSynthDiagnostic[];
68
+ /**
69
+ * chant #2002 — divergences between this evaluation and what `chant build`
70
+ * would do, named rather than left silent. Today that is a project whose
71
+ * resolved `build.sandbox` is `true`: the gate builds unsandboxed, because
72
+ * running it inside the boundary needs the per-execution arming decision
73
+ * tracked on #1157. Also written to stderr — the gate's caller is an
74
+ * activity wrapper that surfaces output, not a structured result.
75
+ */
76
+ warnings: string[];
66
77
  }
67
78
 
68
79
  /**
@@ -70,6 +81,13 @@ export interface PolicyEvaluation {
70
81
  * standalone — used by the `policyGate` Op step to gate an apply on the same
71
82
  * organizational policy `chant build` enforces. Loads the project's lexicons,
72
83
  * builds, then runs the policy pack with `env` (explicit, else `ownership.env`).
84
+ *
85
+ * chant #2002 — "the same organizational policy `chant build` enforces" only
86
+ * holds if it is the same build. The build options are assembled by
87
+ * `../cli/build-options.ts`'s `resolveProjectBuildOptions`, the single function
88
+ * `chant build` also calls, rather than by a second list here; a build option
89
+ * added for one caller and not the other is what let a gate pass a build
90
+ * `chant build` fails.
73
91
  */
74
92
  export async function evaluateProjectPolicies(opts: {
75
93
  path: string;
@@ -96,10 +114,37 @@ export async function evaluateProjectPolicies(opts: {
96
114
  }
97
115
  const env = opts.env ?? resolveOwnershipEnv(config, params.provenance);
98
116
 
99
- const result = await build(buildPath, serializers, undefined, {
100
- ownership: resolveOwnershipMarker(config, params.provenance),
101
- buildParams: params.provenance,
102
- });
117
+ // chant #2002 — the build options come from the shared assembler
118
+ // (`../cli/build-options.ts`), the same one `chant build` uses, so the gate
119
+ // decides on the project `chant build` produces: folded by default (#1134),
120
+ // with the project's config reaching the serializers, and with
121
+ // config-declared build roots contributing their entities. Assembling a
122
+ // second, shorter list here is what let the two drift apart.
123
+ const modes = resolveBuildModes(config);
124
+ const warnings: string[] = [];
125
+ if (modes.sandbox) {
126
+ // Resolved, reported, and not yet honoured. Building inside the boundary
127
+ // needs per-execution arming (#1157) — until that lands, saying so is the
128
+ // difference between a known gap and a silent one.
129
+ warnings.push(
130
+ `build.sandbox is enabled for this project, but the policy gate builds it in this process — the project's source files and its lint.policies modules execute here, unsandboxed (chant #1157). Run \`chant build --sandbox\` for a sandboxed build of the same project.`,
131
+ );
132
+ }
133
+ for (const warning of warnings) console.error(formatWarning({ message: warning }));
134
+
135
+ const result = await build(
136
+ buildPath,
137
+ serializers,
138
+ undefined,
139
+ resolveProjectBuildOptions({
140
+ config,
141
+ configDir,
142
+ plugins,
143
+ modes: { fold: modes.fold, sandbox: false },
144
+ ownership: resolveOwnershipMarker(config, params.provenance),
145
+ buildParams: params.provenance,
146
+ }),
147
+ );
103
148
  if (result.errors.length > 0) {
104
149
  throw new Error("Build failed — cannot evaluate policy on a broken build");
105
150
  }
@@ -108,7 +153,7 @@ export async function evaluateProjectPolicies(opts: {
108
153
  ? await loadPolicyChecks(config.lint.policies, configDir)
109
154
  : [];
110
155
  if (checks.length === 0) {
111
- return { diagnostics: [], violations: [], env, suppressed: [] };
156
+ return { diagnostics: [], violations: [], env, suppressed: [], warnings };
112
157
  }
113
158
 
114
159
  const raw = runPostSynthChecks(checks, result, env);
@@ -118,5 +163,5 @@ export async function evaluateProjectPolicies(opts: {
118
163
  // fails on it either, and vice versa for a check turned UP to "error".
119
164
  const { diagnostics, suppressed } = applyConfiguredSeverity(raw, config.lint?.rules);
120
165
  const violations = diagnostics.filter((d) => d.severity === "error");
121
- return { diagnostics, violations, env, suppressed };
166
+ return { diagnostics, violations, env, suppressed, warnings };
122
167
  }
@@ -136,6 +136,34 @@ export function findGate(config: OpConfig): GateStep | undefined {
136
136
  return undefined;
137
137
  }
138
138
 
139
+ /**
140
+ * Find the first `policyGate` step anywhere in the Op (phases + `onFailure`,
141
+ * including steps nested inside effect steps), if any.
142
+ *
143
+ * chant #2003 — `--sandbox` is a GLOBAL flag (`../cli/registry.ts`), and
144
+ * `../cli/main.ts` arms the process-wide policy latch off it for every command,
145
+ * `chant run` included. A `policyGate` step then reaches `loadPolicyChecks`,
146
+ * which refuses while armed with a message written for a chant maintainer —
147
+ * shown to a user who passed a documented flag. The combination cannot be
148
+ * honoured until the gate can build and load policies inside the boundary
149
+ * (#1157), so both `chant run` paths pre-flight it with this and refuse before
150
+ * any phase executes, the same shape as {@link findGate}'s pre-flight.
151
+ */
152
+ export function findPolicyGateStep(config: OpConfig): ActivityStep | undefined {
153
+ const all = [...config.phases, ...(config.onFailure ?? [])];
154
+ const isPolicyGate = (s: StepDefinition): s is ActivityStep => isActivity(s) && s.fn === "policyGate";
155
+ for (const phase of all) {
156
+ for (const step of phase.steps) {
157
+ if (isPolicyGate(step)) return step;
158
+ if (isEffect(step)) {
159
+ const nested = step.steps.find(isPolicyGate);
160
+ if (nested) return nested;
161
+ }
162
+ }
163
+ }
164
+ return undefined;
165
+ }
166
+
139
167
  const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
140
168
 
141
169
  /**