@intentius/chant 0.51.0 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cli/build-options.d.ts +68 -0
  2. package/dist/cli/build-options.d.ts.map +1 -0
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts +0 -21
  6. package/dist/cli/handlers/run.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/discovery/fold-import.d.ts.map +1 -1
  9. package/dist/lexicon.d.ts +10 -9
  10. package/dist/lexicon.d.ts.map +1 -1
  11. package/dist/lifecycle/assert-live.d.ts +2 -3
  12. package/dist/lifecycle/assert-live.d.ts.map +1 -1
  13. package/dist/lifecycle/change-set.d.ts +23 -0
  14. package/dist/lifecycle/change-set.d.ts.map +1 -1
  15. package/dist/lint/policy.d.ts +16 -0
  16. package/dist/lint/policy.d.ts.map +1 -1
  17. package/dist/op/local-executor.d.ts +15 -1
  18. package/dist/op/local-executor.d.ts.map +1 -1
  19. package/dist/ownership.d.ts +5 -4
  20. package/dist/ownership.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/src/cli/build-options.test.ts +101 -0
  23. package/src/cli/build-options.ts +109 -0
  24. package/src/cli/commands/build.ts +24 -53
  25. package/src/cli/handlers/lifecycle.test.ts +19 -0
  26. package/src/cli/handlers/lifecycle.ts +14 -7
  27. package/src/cli/handlers/run.test.ts +96 -0
  28. package/src/cli/handlers/run.ts +37 -1
  29. package/src/cli/main.ts +2 -0
  30. package/src/discovery/fold-import.test.ts +102 -0
  31. package/src/discovery/fold-import.ts +54 -4
  32. package/src/lexicon.ts +10 -9
  33. package/src/lifecycle/assert-live.ts +2 -3
  34. package/src/lifecycle/change-set.test.ts +144 -1
  35. package/src/lifecycle/change-set.ts +133 -11
  36. package/src/lint/policy-build-parity.test.ts +232 -0
  37. package/src/lint/policy.ts +51 -6
  38. package/src/op/local-executor.ts +28 -0
  39. package/src/ownership.ts +5 -4
package/src/lexicon.ts CHANGED
@@ -1163,10 +1163,10 @@ export interface LexiconPlugin {
1163
1163
  * where the wrong delete gets proposed.
1164
1164
  *
1165
1165
  * Declared per read path, because the answer differs by path. aws stamps tags
1166
- * at synthesis and reads them on the deep observation and on live export,
1167
- * while its `describeResources` is sourced from `describe-stack-resources`,
1168
- * which returns no tags — so an `owned: true` thin read against aws can only
1169
- * answer `unknown`, and does.
1166
+ * at synthesis and reads them per resource on the deep observation and on
1167
+ * live export, while its `describeResources` is sourced from
1168
+ * `describe-stack-resources`, which returns no per-resource tags — so that
1169
+ * path resolves the verdict from the stack's own tags (#1998).
1170
1170
  *
1171
1171
  * Absent means no channel anywhere: every verdict must be `unknown`.
1172
1172
  */
@@ -1417,11 +1417,12 @@ export interface ResourceMetadata {
1417
1417
  *
1418
1418
  * Set only when the live model actually carries the channel and the
1419
1419
  * managed-by marker is present — an absent channel means an absent field,
1420
- * never a guess. In particular aws's thin read is sourced from
1421
- * `describe-stack-resources`, which returns no tags at all, so aws never
1422
- * populates this here; aws teardown is stack-level and reads the stack's own
1423
- * tags instead (later PR). A populated `marker` does not by itself imply an
1424
- * {@link ownership} verdict on paths that do not declare a marker channel.
1420
+ * never a guess. The granularity is whatever the channel reads at: aws's thin
1421
+ * read is sourced from `describe-stack-resources`, which returns no
1422
+ * per-resource tags, so it reads the STACK's own tags and every member of a
1423
+ * marked stack carries that stack's identity (#1998). A populated `marker`
1424
+ * does not by itself imply an {@link ownership} verdict on paths that do not
1425
+ * declare a marker channel.
1425
1426
  */
1426
1427
  marker?: OwnershipMarker;
1427
1428
  /**
@@ -15,9 +15,8 @@
15
15
  *
16
16
  * Marker verification is best-effort by the same logic {@link
17
17
  * ResourceMetadata.marker}'s own contract states: a lexicon with no marker
18
- * channel on this read path (aws's thin `describeResources`, `ownership:
19
- * "unknown"`) reports no marker at all, which is not the same claim as
20
- * "foreign". Enforcing a match whenever the channel exists — a present
18
+ * channel on this read path reports no marker at all, which is not the same
19
+ * claim as "foreign". Enforcing a match whenever the channel exists — a present
21
20
  * mismatch, or `ownership: "foreign"` with no marker to show — catches the
22
21
  * case the harness cares about (a same-named leftover from another env);
23
22
  * an absent channel is passed through unverified rather than making every
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { buildChangeSet, renderChangeSet, summarize, gitlabMrReport } from "./change-set";
2
+ import {
3
+ buildChangeSet,
4
+ renderChangeSet,
5
+ renderChangeSetMarkdown,
6
+ summarize,
7
+ gitlabMrReport,
8
+ unobservedPlanNotice,
9
+ type ChangeSet,
10
+ type ChangeSetEntry,
11
+ } from "./change-set";
3
12
  import type { ResourceMetadata } from "../lexicon";
4
13
 
5
14
  const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
@@ -446,3 +455,137 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
446
455
  expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
447
456
  });
448
457
  });
458
+
459
+ // ── Markdown report (#1983) ──────────────────────────────────────────────
460
+
461
+ function entry(overrides: Partial<ChangeSetEntry> = {}): ChangeSetEntry {
462
+ return {
463
+ name: "db",
464
+ type: "AWS::RDS::DBInstance",
465
+ lexicon: "aws",
466
+ action: "update",
467
+ evidence: { declared: true, inSnapshot: true, live: true, observed: true },
468
+ ownership: "unknown",
469
+ ...overrides,
470
+ };
471
+ }
472
+
473
+ function set(entries: ChangeSetEntry[]): ChangeSet {
474
+ return { env: "prod", entries };
475
+ }
476
+
477
+ describe("unobservedPlanNotice (#1983)", () => {
478
+ test("empty when nothing is unobserved", () => {
479
+ expect(unobservedPlanNotice(set([entry({ action: "noop" })]))).toEqual([]);
480
+ });
481
+
482
+ test("one notice naming the count, matching the CLI's stderr wording", () => {
483
+ const cs = set([
484
+ entry({ name: "a", action: "unobserved", unobservedReason: "no-binding" }),
485
+ entry({ name: "b", action: "unobserved", unobservedReason: "read-failed" }),
486
+ entry({ name: "c", action: "noop" }),
487
+ ]);
488
+ expect(unobservedPlanNotice(cs)).toEqual([
489
+ "2 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.",
490
+ ]);
491
+ });
492
+ });
493
+
494
+ describe("renderChangeSetMarkdown (#1983)", () => {
495
+ test("empty plan: header only, no ANSI, deterministic", () => {
496
+ const out = renderChangeSetMarkdown(set([]));
497
+ expect(out).toBe(
498
+ "## Plan for `prod`\n\n0 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 0 noop, 0 unobserved\n",
499
+ );
500
+ expect(out).not.toMatch(/\x1b\[/);
501
+ });
502
+
503
+ test("counts header and grouped sections, attributed to lexicon", () => {
504
+ const cs = set([
505
+ entry({ name: "new-bucket", type: "S3::Bucket", lexicon: "aws", action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true } }),
506
+ entry({ name: "web", type: "K8s::Apps::Deployment", lexicon: "k8s", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true } }),
507
+ ]);
508
+ const out = renderChangeSetMarkdown(cs);
509
+ expect(out).toContain("## Plan for `prod`");
510
+ expect(out).toContain("1 create, 0 update, 0 effect, 0 delete, 0 adopt, 0 runtime, 1 noop, 0 unobserved");
511
+ expect(out).toContain("### CREATE");
512
+ expect(out).toContain("- `new-bucket` (S3::Bucket) `aws`");
513
+ expect(out).toContain("### NOOP");
514
+ expect(out).toContain("- `web` (K8s::Apps::Deployment) `k8s`");
515
+ });
516
+
517
+ test("deltas render in a fenced block, forced path marked", () => {
518
+ const cs = set([
519
+ entry({
520
+ deltas: [
521
+ { path: "attributes.DBInstanceIdentifier", oldValue: "app-db", newValue: "app-db-2" },
522
+ { path: "attributes.AllocatedStorage", oldValue: 20, newValue: 40 },
523
+ ],
524
+ disruption: "destroy",
525
+ disruptionBecause: ["attributes.DBInstanceIdentifier"],
526
+ disruptionDetail: "DBInstanceIdentifier is create-only",
527
+ }),
528
+ ]);
529
+ const out = renderChangeSetMarkdown(cs);
530
+ expect(out).toContain("**destroy**: DBInstanceIdentifier is create-only");
531
+ expect(out).toContain("```");
532
+ expect(out).toContain("! attributes.DBInstanceIdentifier: app-db → app-db-2");
533
+ expect(out).toContain("attributes.AllocatedStorage: 20 → 40");
534
+ });
535
+
536
+ test("disruption count rides the header, same as the human render", () => {
537
+ const cs = set([entry({ disruption: "in-place" }), entry({ name: "web", disruption: "destroy" })]);
538
+ const out = renderChangeSetMarkdown(cs);
539
+ expect(out).toContain("**Disruption:** 1 in-place, 1 destroy");
540
+ });
541
+
542
+ test("unobserved renders both the prominent notice and its own section, same wording as the human render", () => {
543
+ const cs = set([
544
+ entry({ name: "crd-widget", action: "unobserved", unobservedReason: "no-binding", unobservedDetail: "no kubectl context for prod" }),
545
+ ]);
546
+ const out = renderChangeSetMarkdown(cs);
547
+ expect(out).toContain(
548
+ "> **1 declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.**",
549
+ );
550
+ expect(out).toContain("### UNOBSERVED (declared; chant could not read live state — no action proposed)");
551
+ expect(out).toContain("- `crd-widget`");
552
+ expect(out).toContain("no binding for this environment");
553
+ expect(out).toContain("no kubectl context for prod");
554
+ });
555
+
556
+ test("a clean plan (no holes) carries no unobserved notice at all", () => {
557
+ const out = renderChangeSetMarkdown(set([entry({ action: "noop" })]));
558
+ expect(out).not.toContain("could not be observed");
559
+ expect(out).not.toContain("UNOBSERVED");
560
+ });
561
+
562
+ test("effect entries render as their own line, no deltas", () => {
563
+ const cs = set([
564
+ entry({
565
+ name: "receipt-x",
566
+ action: "effect",
567
+ effect: "seed-admin",
568
+ effectReason: "receipt-absent",
569
+ effectDetail: "no receipt recorded yet",
570
+ deltas: undefined,
571
+ }),
572
+ ]);
573
+ const out = renderChangeSetMarkdown(cs);
574
+ expect(out).toContain("- effect will fire: `seed-admin` — receipt `receipt-x`");
575
+ expect(out).toContain("no receipt recorded yet");
576
+ });
577
+
578
+ test("a group past the fold threshold collapses into <details>, a small one does not", () => {
579
+ const many = Array.from({ length: 25 }, (_, i) =>
580
+ entry({ name: `bucket-${i}`, action: "create", evidence: { declared: true, inSnapshot: false, live: false, observed: true }, deltas: undefined }),
581
+ );
582
+ const out = renderChangeSetMarkdown(set(many));
583
+ expect(out).toContain("<details><summary>25 entries — click to expand</summary>");
584
+ expect(out).toContain("</details>");
585
+ expect(out).toContain("- `bucket-0`");
586
+ expect(out).toContain("- `bucket-24`");
587
+
588
+ const few = renderChangeSetMarkdown(set(many.slice(0, 3)));
589
+ expect(few).not.toContain("<details>");
590
+ });
591
+ });
@@ -325,6 +325,45 @@ export function gitlabMrReport(cs: ChangeSet): GitlabMrReport {
325
325
  return { create: counts.create, update: counts.update, delete: counts.delete };
326
326
  }
327
327
 
328
+ /**
329
+ * Warning a plan should print when it carries a hole — a declared entity the
330
+ * lexicon could not observe (#1089). The `--json` and `--report gitlab-mr`
331
+ * shapes have no column for `unobserved`, so both the CLI (on stderr) and the
332
+ * `markdown` report (in the body, since a reviewer never sees a job's stderr)
333
+ * read this same wording rather than drifting apart. Empty when the plan has
334
+ * no hole. Same discipline as {@link disruptionNotices} in `./disruption`.
335
+ */
336
+ export function unobservedPlanNotice(cs: ChangeSet): string[] {
337
+ const count = summarize(cs).unobserved;
338
+ return count > 0
339
+ ? [
340
+ `${count} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
341
+ ]
342
+ : [];
343
+ }
344
+
345
+ /**
346
+ * Section heading text for one action group — shared between {@link renderChangeSet}
347
+ * and {@link renderChangeSetMarkdown} so the wording never drifts between the
348
+ * terminal render and the reviewer-facing one.
349
+ */
350
+ function actionSectionLabel(action: ChangeAction, hasDisruption: boolean): string {
351
+ switch (action) {
352
+ case "unobserved":
353
+ return "UNOBSERVED (declared; chant could not read live state — no action proposed)";
354
+ case "runtime":
355
+ return "RUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate)";
356
+ case "effect":
357
+ return "EFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt)";
358
+ case "update":
359
+ return hasDisruption
360
+ ? "UPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe)"
361
+ : "UPDATE";
362
+ default:
363
+ return action.toUpperCase();
364
+ }
365
+ }
366
+
328
367
  /** Human-readable render of a change set. Pure — returns a string. */
329
368
  export function renderChangeSet(cs: ChangeSet): string {
330
369
  const counts = summarize(cs);
@@ -344,17 +383,7 @@ export function renderChangeSet(cs: ChangeSet): string {
344
383
  for (const action of ACTION_ORDER) {
345
384
  const group = cs.entries.filter((e) => e.action === action);
346
385
  if (group.length === 0) continue;
347
- lines.push(
348
- action === "unobserved"
349
- ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
350
- : action === "runtime"
351
- ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
352
- : action === "effect"
353
- ? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
354
- : action === "update" && disruptionParts.length > 0
355
- ? "\nUPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):"
356
- : `\n${action.toUpperCase()}:`,
357
- );
386
+ lines.push(`\n${actionSectionLabel(action, disruptionParts.length > 0)}:`);
358
387
  for (const e of group) {
359
388
  if (e.action === "effect") {
360
389
  lines.push(
@@ -381,6 +410,99 @@ export function renderChangeSet(cs: ChangeSet): string {
381
410
  return lines.join("\n");
382
411
  }
383
412
 
413
+ /**
414
+ * Entries beyond this in one action group are collapsed into a `<details>`
415
+ * block in {@link renderChangeSetMarkdown} — a group's worth of rows is fine
416
+ * to read inline, a hundred-entry plan is not (#1983).
417
+ */
418
+ const MARKDOWN_FOLD_THRESHOLD = 20;
419
+
420
+ /** One entry's markdown, mirroring the rows {@link renderChangeSet} prints. */
421
+ function renderMarkdownEntry(e: ChangeSetEntry): string[] {
422
+ if (e.action === "effect") {
423
+ return [
424
+ `- effect will fire: \`${e.effect ?? e.name}\` — receipt \`${e.name}\`${e.type ? ` (${e.type})` : ""}` +
425
+ `${e.effectDetail ? ` — ${e.effectDetail}` : ""}`,
426
+ ];
427
+ }
428
+ const lexicon = e.lexicon ? ` \`${e.lexicon}\`` : "";
429
+ const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
430
+ const why = e.unobservedReason
431
+ ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
432
+ : "";
433
+ const owner = e.runtimeOwner ? ` — owned by \`${e.runtimeOwner}\`` : "";
434
+ // Bolded rather than the plain-text render's bare " — destroy: ..." — a
435
+ // reviewer scanning a wall of "update" rows should see disruption without
436
+ // reading every one (#1665).
437
+ const disruption = e.disruption
438
+ ? ` — **${e.disruption}**${e.disruptionDetail ? `: ${e.disruptionDetail}` : ""}`
439
+ : "";
440
+ const lines = [`- \`${e.name}\`${e.type ? ` (${e.type})` : ""}${lexicon}${own}${why}${owner}${disruption}`];
441
+
442
+ if (e.deltas && e.deltas.length > 0) {
443
+ const forced = new Set(e.disruptionBecause ?? []);
444
+ lines.push(" ```");
445
+ for (const d of e.deltas) {
446
+ lines.push(` ${forced.has(d.path) ? "! " : " "}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
447
+ }
448
+ lines.push(" ```");
449
+ }
450
+ return lines;
451
+ }
452
+
453
+ /**
454
+ * Markdown projection of a change set (#1983) — sized for a PR/MR comment
455
+ * rather than a terminal. A scannable counts header, entries grouped by
456
+ * action and attributed to their lexicon, deltas in a fenced block, and any
457
+ * group past {@link MARKDOWN_FOLD_THRESHOLD} folded into a `<details>` so a
458
+ * large plan doesn't bury the comment. Pure — no ANSI, no I/O — and
459
+ * deterministic: same entry ordering as {@link renderChangeSet}.
460
+ *
461
+ * Both a hole (#1089) and an expensive verdict (#1665) are surfaced IN the
462
+ * body, not left to stderr the way `--json` and `--report gitlab-mr` leave
463
+ * them: a reviewer reading a comment never sees the job's log, so a plan
464
+ * carrying either must not read as clean.
465
+ */
466
+ export function renderChangeSetMarkdown(cs: ChangeSet): string {
467
+ const counts = summarize(cs);
468
+ const lines: string[] = [`## Plan for \`${cs.env}\``, "", ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ")];
469
+
470
+ const disruption = summarizeDisruption(cs);
471
+ const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
472
+ .filter(([, n]) => n > 0)
473
+ .map(([level, n]) => `${n} ${level}`);
474
+ if (disruptionParts.length > 0) {
475
+ lines.push("", `**Disruption:** ${disruptionParts.join(", ")}`);
476
+ }
477
+
478
+ for (const notice of unobservedPlanNotice(cs)) {
479
+ lines.push("", `> **${notice}**`);
480
+ }
481
+
482
+ for (const action of ACTION_ORDER) {
483
+ const group = cs.entries.filter((e) => e.action === action);
484
+ if (group.length === 0) continue;
485
+
486
+ lines.push("", `### ${actionSectionLabel(action, disruptionParts.length > 0)}`);
487
+
488
+ const body = group.flatMap((e) => renderMarkdownEntry(e));
489
+ if (group.length > MARKDOWN_FOLD_THRESHOLD) {
490
+ lines.push(
491
+ "",
492
+ `<details><summary>${group.length} entries — click to expand</summary>`,
493
+ "",
494
+ ...body,
495
+ "",
496
+ "</details>",
497
+ );
498
+ } else {
499
+ lines.push("", ...body);
500
+ }
501
+ }
502
+
503
+ return lines.join("\n").trimEnd() + "\n";
504
+ }
505
+
384
506
  function fmt(v: unknown): string {
385
507
  if (v === undefined) return "<unset>";
386
508
  if (typeof v === "string") return v.length > 60 ? v.slice(0, 57) + "..." : v;
@@ -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
+ });