@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs } from "./build";
2
+ import { build, partitionByLexicon, detectCrossLexiconRefs, collectLexiconOutputs, computeStackGraph } from "./build";
3
3
  import { output } from "./lexicon-output";
4
4
  import { AttrRef } from "./attrref";
5
5
  import { INTRINSIC_MARKER } from "./intrinsic";
@@ -462,3 +462,50 @@ describe("detectCrossLexiconRefs", () => {
462
462
  expect(detected[0].outputName).toBe("dataBucket_Endpoint");
463
463
  });
464
464
  });
465
+
466
+ describe("computeStackGraph (#200 — cross-stack apply ordering)", () => {
467
+ const ent = (lexicon: string, props: Record<string, unknown> = {}): Declarable =>
468
+ ({ lexicon, entityType: `${lexicon}::X`, [DECLARABLE_MARKER]: true, props }) as unknown as Declarable;
469
+
470
+ test("infers a consumer→producer edge from a cross-lexicon AttrRef", () => {
471
+ const vpc = ent("aws");
472
+ const svc = ent("k8s", { vpcId: new AttrRef(vpc, "id") });
473
+ const g = computeStackGraph(new Map([["vpc", vpc], ["svc", svc]]), ["aws", "k8s"]);
474
+
475
+ expect(g.edges).toEqual([{ from: "k8s", to: "aws" }]);
476
+ expect(g.order).toEqual(["aws", "k8s"]); // producer before consumer
477
+ expect(g.waves).toEqual([["aws"], ["k8s"]]); // separate waves — ordered
478
+ expect(g.cycles).toEqual([]);
479
+ });
480
+
481
+ test("independent stacks share a wave (parallel-safe)", () => {
482
+ const g = computeStackGraph(new Map([["a", ent("aws")], ["b", ent("gcp")]]), ["aws", "gcp"]);
483
+ expect(g.edges).toEqual([]);
484
+ expect(g.waves).toEqual([["aws", "gcp"]]); // one wave — no inter-dependency
485
+ });
486
+
487
+ test("reports a dependency cycle", () => {
488
+ const a = ent("x");
489
+ const b = ent("y", { ref: new AttrRef(a, "out") });
490
+ // close the loop: a references b
491
+ (a as unknown as { props: Record<string, unknown> }).props = { ref: new AttrRef(b, "out") };
492
+ const g = computeStackGraph(new Map([["a", a], ["b", b]]), ["x", "y"]);
493
+
494
+ expect(g.edges).toEqual(expect.arrayContaining([{ from: "x", to: "y" }, { from: "y", to: "x" }]));
495
+ expect(g.cycles).toEqual([["x", "y"]]);
496
+ expect(g.order).toEqual([]); // nothing is orderable inside a cycle
497
+ });
498
+
499
+ test("a diamond resolves into three waves", () => {
500
+ // base ← (left, right) ← top
501
+ const base = ent("base");
502
+ const left = ent("left", { b: new AttrRef(base, "id") });
503
+ const right = ent("right", { b: new AttrRef(base, "id") });
504
+ const top = ent("top", { l: new AttrRef(left, "id"), r: new AttrRef(right, "id") });
505
+ const g = computeStackGraph(
506
+ new Map([["base", base], ["left", left], ["right", right], ["top", top]]),
507
+ ["base", "left", "right", "top"],
508
+ );
509
+ expect(g.waves).toEqual([["base"], ["left", "right"], ["top"]]);
510
+ });
511
+ });
package/src/build.ts CHANGED
@@ -20,6 +20,115 @@ export interface BuildManifest {
20
20
  { source: string; entity: string; attribute: string }
21
21
  >;
22
22
  deployOrder: string[];
23
+ /** Cross-stack apply-ordering graph (see {@link computeStackGraph}). */
24
+ stackGraph: StackGraph;
25
+ }
26
+
27
+ /**
28
+ * The cross-stack (cross-lexicon) apply-ordering graph chant already computes
29
+ * while resolving cross-lexicon references — surfaced as tool-agnostic data for
30
+ * an orchestrator to consume. chant exposes the order; it does not drive the
31
+ * apply.
32
+ */
33
+ export interface StackGraph {
34
+ /** Stacks (lexicon partitions) in the build. */
35
+ nodes: string[];
36
+ /**
37
+ * Consumer→producer edges: `from` imports a value `to` exports, so `to` must
38
+ * apply before `from`. Inferred from cross-lexicon references.
39
+ */
40
+ edges: Array<{ from: string; to: string }>;
41
+ /** A flat applicable sequence — every producer before its consumers. */
42
+ order: string[];
43
+ /**
44
+ * Levels: stacks in the same wave have no inter-dependency and may apply
45
+ * concurrently. `order` flattened with parallelism made explicit.
46
+ */
47
+ waves: string[][];
48
+ /** Dependency cycles, if any (each a list of stacks). Normally empty. */
49
+ cycles: string[][];
50
+ }
51
+
52
+ /**
53
+ * Compute the cross-stack apply-ordering graph from resolved entities. Edges are
54
+ * inferred from cross-lexicon attribute references (a resource in lexicon A
55
+ * referencing an attribute of a resource in lexicon B ⇒ A depends on B). Returns
56
+ * the edge set plus a topological order, parallel-safe waves, and any cycles.
57
+ */
58
+ export function computeStackGraph(
59
+ entities: Map<string, Declarable>,
60
+ lexiconNames: string[],
61
+ ): StackGraph {
62
+ const edges: Array<{ from: string; to: string }> = [];
63
+ const edgeSet = new Set<string>();
64
+ const addEdge = (from: string, to: string): void => {
65
+ if (from === to) return;
66
+ const key = `${from}\0${to}`;
67
+ if (edgeSet.has(key)) return;
68
+ edgeSet.add(key);
69
+ edges.push({ from, to });
70
+ };
71
+
72
+ const walk = (value: unknown, consumer: string, visited: Set<unknown>): void => {
73
+ if (value === null || value === undefined || typeof value !== "object") return;
74
+ if (visited.has(value)) return;
75
+ visited.add(value);
76
+ if (value instanceof AttrRef) {
77
+ const parent = value.parent.deref();
78
+ const producer = parent ? (parent as Record<string, unknown>).lexicon : undefined;
79
+ if (typeof producer === "string" && producer !== consumer) addEdge(consumer, producer);
80
+ return;
81
+ }
82
+ if (isLexiconOutput(value)) return;
83
+ if (Array.isArray(value)) {
84
+ for (const item of value) walk(item, consumer, visited);
85
+ return;
86
+ }
87
+ for (const val of Object.values(value as Record<string, unknown>)) walk(val, consumer, visited);
88
+ };
89
+
90
+ for (const [, entity] of entities) {
91
+ const consumer = entity.lexicon;
92
+ const visited = new Set<unknown>();
93
+ for (const val of Object.values(entity as unknown as Record<string, unknown>)) {
94
+ walk(val, consumer, visited);
95
+ }
96
+ if ("props" in entity && typeof entity.props === "object" && entity.props !== null) {
97
+ walk(entity.props, consumer, visited);
98
+ }
99
+ }
100
+
101
+ // Dependency map: node → producers it depends on.
102
+ const nodes = lexiconNames.length
103
+ ? [...lexiconNames]
104
+ : [...new Set([...entities.values()].map((e) => e.lexicon))];
105
+ const deps = new Map<string, Set<string>>();
106
+ for (const n of nodes) deps.set(n, new Set());
107
+ for (const { from, to } of edges) {
108
+ if (!deps.has(from)) deps.set(from, new Set());
109
+ if (!deps.has(to)) deps.set(to, new Set());
110
+ deps.get(from)!.add(to);
111
+ }
112
+
113
+ // Kahn layering: a node is ready once every producer it depends on is placed.
114
+ const remaining = new Set(deps.keys());
115
+ const waves: string[][] = [];
116
+ const order: string[] = [];
117
+ while (remaining.size > 0) {
118
+ const wave = [...remaining]
119
+ .filter((n) => [...deps.get(n)!].every((d) => !remaining.has(d)))
120
+ .sort();
121
+ if (wave.length === 0) break; // remaining nodes form a cycle
122
+ for (const n of wave) {
123
+ remaining.delete(n);
124
+ order.push(n);
125
+ }
126
+ waves.push(wave);
127
+ }
128
+ const cycles: string[][] = remaining.size > 0 ? [[...remaining].sort()] : [];
129
+
130
+ edges.sort((a, b) => `${a.from}\0${a.to}`.localeCompare(`${b.from}\0${b.to}`));
131
+ return { nodes: [...nodes].sort(), edges, order, waves, cycles };
23
132
  }
24
133
 
25
134
  /**
@@ -288,7 +397,8 @@ function computeDeployOrder(
288
397
  */
289
398
  function generateManifest(
290
399
  lexiconNames: string[],
291
- lexiconOutputs: LexiconOutput[]
400
+ lexiconOutputs: LexiconOutput[],
401
+ entities: Map<string, Declarable>
292
402
  ): BuildManifest {
293
403
  const outputsRecord: Record<
294
404
  string,
@@ -307,6 +417,7 @@ function generateManifest(
307
417
  lexicons: lexiconNames,
308
418
  outputs: outputsRecord,
309
419
  deployOrder: computeDeployOrder(lexiconNames, lexiconOutputs),
420
+ stackGraph: computeStackGraph(entities, lexiconNames),
310
421
  };
311
422
  }
312
423
 
@@ -453,7 +564,7 @@ export async function build(
453
564
 
454
565
  // Step 8: Generate manifest
455
566
  const lexiconNames = Array.from(partitions.keys());
456
- const manifest = generateManifest(lexiconNames, lexiconOutputs);
567
+ const manifest = generateManifest(lexiconNames, lexiconOutputs, discoveryResult.entities);
457
568
 
458
569
  return {
459
570
  outputs,
@@ -3,7 +3,7 @@ import { loadChantConfig, resolveOwnershipMarker } from "../../config";
3
3
  import type { Serializer, SerializerResult } from "../../serializer";
4
4
  import type { LexiconPlugin } from "../../lexicon";
5
5
  import { runPostSynthChecks } from "../../lint/post-synth";
6
- import type { PostSynthCheck } from "../../lint/post-synth";
6
+ import { loadPolicyChecks } from "../../lint/policy";
7
7
  import { sortedJsonReplacer } from "../../utils";
8
8
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
9
9
  import { writeFileSync, mkdirSync } from "fs";
@@ -26,8 +26,15 @@ export interface BuildOptions {
26
26
  plugins?: LexiconPlugin[];
27
27
  /** Print summary to stderr */
28
28
  verbose?: boolean;
29
+ /**
30
+ * Environment/stack to evaluate policy against (`--env`). Falls back to the
31
+ * project's `ownership.env`. Passed into post-synth checks so organizational
32
+ * policy can branch on environment.
33
+ */
34
+ env?: string;
29
35
  }
30
36
 
37
+
31
38
  /**
32
39
  * Build command result
33
40
  */
@@ -56,11 +63,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
56
63
 
57
64
  // Resolve opt-in ownership marking from project config (search the infra dir
58
65
  // and its parent, where chant.config.* usually lives).
59
- const { config } = await loadChantConfig(infraPath).then((r) =>
66
+ const loaded = await loadChantConfig(infraPath).then((r) =>
60
67
  r.configPath ? r : loadChantConfig(dirname(infraPath)),
61
68
  );
69
+ const config = loaded.config;
62
70
  const ownership = resolveOwnershipMarker(config);
63
71
 
72
+ // Environment for policy evaluation: explicit --env wins, else ownership.env.
73
+ const env = options.env ?? config.ownership?.env;
74
+ // Project-authored organizational policy checks (lint.policies), run over the
75
+ // resolved resources during build. Resolve paths relative to the config dir.
76
+ const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
77
+ const policyChecks = config.lint?.policies?.length
78
+ ? await loadPolicyChecks(config.lint.policies, configDir)
79
+ : [];
80
+
64
81
  // Run the build
65
82
  const result = await build(infraPath, options.serializers, undefined, { ownership });
66
83
 
@@ -96,7 +113,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
96
113
  }
97
114
 
98
115
  const scopedResult = { ...result, outputs: scopedOutputs };
99
- const postDiags = runPostSynthChecks(checks, scopedResult);
116
+ const postDiags = runPostSynthChecks(checks, scopedResult, env);
100
117
  for (const diag of postDiags) {
101
118
  const prefix = diag.entity ? `[${diag.entity}] ` : "";
102
119
  const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
@@ -107,6 +124,19 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
107
124
  }
108
125
  }
109
126
  }
127
+
128
+ // Project-authored organizational policy — cross-cutting, so it sees every
129
+ // lexicon's output at once (not scoped per-plugin), with the current env.
130
+ if (policyChecks.length > 0) {
131
+ const policyDiags = runPostSynthChecks(policyChecks, result, env);
132
+ for (const diag of policyDiags) {
133
+ const prefix = diag.entity ? `[${diag.entity}] ` : "";
134
+ const where = diag.lexicon ? ` (${diag.lexicon})` : "";
135
+ const msg = `[policy:${diag.checkId}] ${prefix}${diag.message}${where}`;
136
+ if (diag.severity === "error") errors.push(formatError({ message: msg }));
137
+ else warnings.push(formatWarning({ message: msg }));
138
+ }
139
+ }
110
140
  }
111
141
 
112
142
  // Empty-output guard: source files were discovered but no lexicon produced
@@ -0,0 +1,148 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import {
6
+ vendorPull,
7
+ vendorCheck,
8
+ contentHash,
9
+ scopeArchiveFiles,
10
+ loadManifest,
11
+ MANIFEST_FILE,
12
+ } from "./vendor";
13
+
14
+ let root: string;
15
+
16
+ function write(rel: string, content: string): void {
17
+ const full = join(root, rel);
18
+ mkdirSync(join(full, ".."), { recursive: true });
19
+ writeFileSync(full, content);
20
+ }
21
+
22
+ beforeEach(() => {
23
+ root = mkdtempSync(join(tmpdir(), "chant-vendor-"));
24
+ // A local "shared pattern" source.
25
+ write("shared/web-app/index.ts", "export const WebApp = () => ({});\n");
26
+ write("shared/web-app/README.md", "# pattern\n");
27
+ // A project that vendors it.
28
+ writeFileSync(
29
+ join(root, MANIFEST_FILE),
30
+ JSON.stringify({
31
+ vendored: [
32
+ { name: "web-app", source: { type: "local", path: "shared/web-app" }, target: "vendor/web-app", ref: "v1" },
33
+ ],
34
+ }, null, 2),
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ rmSync(root, { recursive: true, force: true });
40
+ });
41
+
42
+ describe("vendor pull", () => {
43
+ test("copies the source into target and records a checksum", async () => {
44
+ const result = await vendorPull(root);
45
+ expect(result.success).toBe(true);
46
+ expect(result.pulled[0].fileCount).toBe(2);
47
+
48
+ expect(existsSync(join(root, "vendor/web-app/index.ts"))).toBe(true);
49
+ expect(readFileSync(join(root, "vendor/web-app/README.md"), "utf-8")).toContain("# pattern");
50
+
51
+ // Checksum written back into the manifest.
52
+ const { manifest } = loadManifest(root);
53
+ expect(manifest.vendored[0].checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
54
+ });
55
+
56
+ test("pull <name> filters to one entry; unknown name fails", async () => {
57
+ expect((await vendorPull(root, "web-app")).pulled).toHaveLength(1);
58
+ const miss = await vendorPull(root, "nope");
59
+ expect(miss.success).toBe(false);
60
+ expect(miss.output).toContain("nope");
61
+ });
62
+
63
+ test("fails clearly when the local source is missing", async () => {
64
+ writeFileSync(
65
+ join(root, MANIFEST_FILE),
66
+ JSON.stringify({ vendored: [{ name: "x", source: { type: "local", path: "missing" }, target: "vendor/x" }] }),
67
+ );
68
+ await expect(vendorPull(root)).rejects.toThrow(/local source not found/);
69
+ });
70
+ });
71
+
72
+ describe("vendor check", () => {
73
+ test("reports ok right after a pull", async () => {
74
+ await vendorPull(root);
75
+ const result = vendorCheck(root);
76
+ expect(result.drift).toBe(false);
77
+ expect(result.entries[0].status).toBe("ok");
78
+ });
79
+
80
+ test("detects drift when a vendored file is edited", async () => {
81
+ await vendorPull(root);
82
+ writeFileSync(join(root, "vendor/web-app/index.ts"), "// locally edited\n");
83
+ const result = vendorCheck(root);
84
+ expect(result.drift).toBe(true);
85
+ expect(result.entries[0].status).toBe("drifted");
86
+ });
87
+
88
+ test("flags a target deleted after pinning as missing", async () => {
89
+ await vendorPull(root);
90
+ rmSync(join(root, "vendor/web-app"), { recursive: true });
91
+ const result = vendorCheck(root);
92
+ expect(result.entries[0].status).toBe("missing");
93
+ expect(result.drift).toBe(true);
94
+ });
95
+
96
+ test("an entry with no checksum is unpinned, not drift", async () => {
97
+ // Manifest never pulled → no checksum recorded.
98
+ const result = vendorCheck(root);
99
+ expect(result.entries[0].status).toBe("unpinned");
100
+ expect(result.drift).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("manifest validation", () => {
105
+ test("rejects an invalid manifest", () => {
106
+ writeFileSync(join(root, MANIFEST_FILE), JSON.stringify({ vendored: [{ name: "x" }] }));
107
+ expect(() => loadManifest(root)).toThrow(/invalid vendor.json/);
108
+ });
109
+
110
+ test("rejects a floating updatePolicy (pins only)", () => {
111
+ writeFileSync(
112
+ join(root, MANIFEST_FILE),
113
+ JSON.stringify({ vendored: [{ name: "x", source: { type: "local", path: "shared/web-app" }, target: "v/x", updatePolicy: "latest" }] }),
114
+ );
115
+ expect(() => loadManifest(root)).toThrow(/invalid vendor.json/);
116
+ });
117
+ });
118
+
119
+ describe("contentHash", () => {
120
+ test("is order-independent and content-sensitive", () => {
121
+ const a = new Map([["a", Buffer.from("1")], ["b", Buffer.from("2")]]);
122
+ const b = new Map([["b", Buffer.from("2")], ["a", Buffer.from("1")]]);
123
+ expect(contentHash(a)).toBe(contentHash(b)); // order doesn't matter
124
+ const c = new Map([["a", Buffer.from("1")], ["b", Buffer.from("CHANGED")]]);
125
+ expect(contentHash(a)).not.toBe(contentHash(c));
126
+ });
127
+ });
128
+
129
+ describe("scopeArchiveFiles", () => {
130
+ test("strips the top-level wrapper dir and scopes to a subpath", () => {
131
+ const raw = new Map([
132
+ ["repo-main/composites/web-app/index.ts", Buffer.from("a")],
133
+ ["repo-main/composites/web-app/util.ts", Buffer.from("b")],
134
+ ["repo-main/ops/deploy.op.ts", Buffer.from("c")],
135
+ ["repo-main/README.md", Buffer.from("d")],
136
+ ]);
137
+ const scoped = scopeArchiveFiles(raw, "composites/web-app");
138
+ expect([...scoped.keys()].sort()).toEqual(["index.ts", "util.ts"]);
139
+ });
140
+
141
+ test("no subpath keeps everything below the wrapper dir", () => {
142
+ const raw = new Map([
143
+ ["repo-main/a.ts", Buffer.from("a")],
144
+ ["repo-main/sub/b.ts", Buffer.from("b")],
145
+ ]);
146
+ expect([...scopeArchiveFiles(raw).keys()].sort()).toEqual(["a.ts", "sub/b.ts"]);
147
+ });
148
+ });