@intentius/chant-lexicon-cedar 0.44.8 → 0.44.9

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 (108) hide show
  1. package/README.md +58 -0
  2. package/dist/codegen/package.d.ts.map +1 -1
  3. package/dist/config.d.ts +25 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/dogwood/cli.d.ts +206 -0
  6. package/dist/dogwood/cli.d.ts.map +1 -0
  7. package/dist/dogwood/event-schema.d.ts +161 -0
  8. package/dist/dogwood/event-schema.d.ts.map +1 -0
  9. package/dist/dogwood/index.d.ts +33 -0
  10. package/dist/dogwood/index.d.ts.map +1 -0
  11. package/dist/dogwood/macros.d.ts +96 -0
  12. package/dist/dogwood/macros.d.ts.map +1 -0
  13. package/dist/dogwood/policy.d.ts +120 -0
  14. package/dist/dogwood/policy.d.ts.map +1 -0
  15. package/dist/dogwood/scan.d.ts +109 -0
  16. package/dist/dogwood/scan.d.ts.map +1 -0
  17. package/dist/dogwood/serialize.d.ts +46 -0
  18. package/dist/dogwood/serialize.d.ts.map +1 -0
  19. package/dist/dogwood/temporal.d.ts +259 -0
  20. package/dist/dogwood/temporal.d.ts.map +1 -0
  21. package/dist/dogwood/upstream.d.ts +41 -0
  22. package/dist/dogwood/upstream.d.ts.map +1 -0
  23. package/dist/dogwood/window.d.ts +73 -0
  24. package/dist/dogwood/window.d.ts.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/integrity.json +13 -3
  28. package/dist/lint/audit-catalog.d.ts.map +1 -1
  29. package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
  30. package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
  31. package/dist/lint/post-synth/dwdc010.d.ts +25 -0
  32. package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
  33. package/dist/lint/post-synth/dwdc011.d.ts +19 -0
  34. package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
  35. package/dist/lint/post-synth/dwdc012.d.ts +21 -0
  36. package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
  37. package/dist/lint/post-synth/dwde010.d.ts +32 -0
  38. package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
  39. package/dist/lint/post-synth/dwde011.d.ts +33 -0
  40. package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
  41. package/dist/lint/post-synth/dwds010.d.ts +24 -0
  42. package/dist/lint/post-synth/dwds010.d.ts.map +1 -0
  43. package/dist/lint/post-synth/index.d.ts.map +1 -1
  44. package/dist/manifest.json +1 -1
  45. package/dist/okf/index.md +6 -0
  46. package/dist/okf/rules/DWDC010.md +11 -0
  47. package/dist/okf/rules/DWDC011.md +11 -0
  48. package/dist/okf/rules/DWDC012.md +11 -0
  49. package/dist/okf/rules/DWDE010.md +11 -0
  50. package/dist/okf/rules/DWDE011.md +11 -0
  51. package/dist/okf/rules/DWDS010.md +11 -0
  52. package/dist/policy-text.d.ts +53 -0
  53. package/dist/policy-text.d.ts.map +1 -0
  54. package/dist/rules/dogwood-helpers.ts +139 -0
  55. package/dist/rules/dwdc010.ts +62 -0
  56. package/dist/rules/dwdc011.ts +61 -0
  57. package/dist/rules/dwdc012.ts +46 -0
  58. package/dist/rules/dwde010.ts +130 -0
  59. package/dist/rules/dwde011.ts +108 -0
  60. package/dist/rules/dwds010.ts +46 -0
  61. package/dist/serializer.d.ts +10 -18
  62. package/dist/serializer.d.ts.map +1 -1
  63. package/dist/skills/chant-cedar-authoring.md +180 -0
  64. package/dist/skills/chant-cedar-avp-embedding.md +125 -0
  65. package/dist/skills/chant-cedar-meta-policy.md +119 -0
  66. package/package.json +2 -2
  67. package/src/codegen/package.ts +3 -2
  68. package/src/config.test.ts +12 -0
  69. package/src/config.ts +28 -0
  70. package/src/dogwood/cli.test.ts +392 -0
  71. package/src/dogwood/cli.ts +545 -0
  72. package/src/dogwood/event-schema.test.ts +218 -0
  73. package/src/dogwood/event-schema.ts +318 -0
  74. package/src/dogwood/index.ts +198 -0
  75. package/src/dogwood/macros.test.ts +104 -0
  76. package/src/dogwood/macros.ts +229 -0
  77. package/src/dogwood/policy.test.ts +94 -0
  78. package/src/dogwood/policy.ts +141 -0
  79. package/src/dogwood/scan.ts +287 -0
  80. package/src/dogwood/serialize.test.ts +331 -0
  81. package/src/dogwood/serialize.ts +209 -0
  82. package/src/dogwood/temporal.test.ts +272 -0
  83. package/src/dogwood/temporal.ts +592 -0
  84. package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
  85. package/src/dogwood/testdata/default-macros.dw +23 -0
  86. package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
  87. package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
  88. package/src/dogwood/testdata/pinned.dwschema +31 -0
  89. package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
  90. package/src/dogwood/testdata/read-after-login.dw +17 -0
  91. package/src/dogwood/testdata/temporal-policies.dw +53 -0
  92. package/src/dogwood/upstream.ts +41 -0
  93. package/src/dogwood/window.ts +124 -0
  94. package/src/index.ts +24 -0
  95. package/src/lint/audit-catalog.ts +56 -0
  96. package/src/lint/post-synth/dogwood-helpers.ts +139 -0
  97. package/src/lint/post-synth/dwd-post-synth.test.ts +256 -0
  98. package/src/lint/post-synth/dwdc010.ts +62 -0
  99. package/src/lint/post-synth/dwdc011.ts +61 -0
  100. package/src/lint/post-synth/dwdc012.ts +46 -0
  101. package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
  102. package/src/lint/post-synth/dwde010.ts +130 -0
  103. package/src/lint/post-synth/dwde011.ts +108 -0
  104. package/src/lint/post-synth/dwds010.ts +46 -0
  105. package/src/lint/post-synth/index.ts +12 -0
  106. package/src/lint/post-synth/post-synth.test.ts +7 -3
  107. package/src/policy-text.ts +128 -0
  108. package/src/serializer.ts +71 -109
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The `.dw` serializer leg, through the cedar serializer that hosts it.
3
+ *
4
+ * Exercised end to end rather than through `serializeDogwood` directly,
5
+ * because the thing worth pinning is that a build holding both kinds of policy
6
+ * emits both artifacts, from one pass, with ids derived the same way.
7
+ */
8
+ import { describe, test, expect } from "vitest";
9
+ import { readFileSync } from "fs";
10
+ import { join } from "path";
11
+ import { fileURLToPath } from "url";
12
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
13
+ import type { SerializerResult } from "@intentius/chant/serializer";
14
+ import { cedarSerializer, CEDAR_JSON_FILENAME } from "../serializer";
15
+ import {
16
+ DOGWOOD_EVENT_SCHEMA_FILENAME,
17
+ DOGWOOD_EVENT_SCHEMA_TYPE,
18
+ DOGWOOD_MACRO_FILENAME,
19
+ DOGWOOD_MACRO_LIBRARY_TYPE,
20
+ DOGWOOD_POLICY_FILENAME,
21
+ DOGWOOD_POLICY_TYPE,
22
+ } from "./policy";
23
+ import {
24
+ and,
25
+ call,
26
+ compare,
27
+ count,
28
+ ctx,
29
+ exists,
30
+ formerly,
31
+ interval,
32
+ predicate,
33
+ since,
34
+ sum,
35
+ tp,
36
+ typedBinder,
37
+ varRef,
38
+ } from "./temporal";
39
+ import { defTemporalMacro, defaultMacroLibrary, macroCondition, macroWindow, renderMacroLibrary } from "./macros";
40
+ import { defaultEventSchema } from "./event-schema";
41
+
42
+ const TESTDATA = join(fileURLToPath(new URL(".", import.meta.url)), "testdata");
43
+ const UPSTREAM = "dogwood-policy/dogwood@5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c";
44
+
45
+ function fixture(name: string): string {
46
+ return readFileSync(join(TESTDATA, name), "utf-8");
47
+ }
48
+
49
+ function attribution(...sources: string[]): string {
50
+ return [
51
+ `// Adapted from ${UPSTREAM} (Apache-2.0):`,
52
+ ...sources.map((s) => `// ${s}`),
53
+ "// Upstream's shape; the bytes are what chant's typed builders emit.",
54
+ "",
55
+ "",
56
+ ].join("\n");
57
+ }
58
+
59
+ function mock(entityType: string, props: Record<string, unknown>): Declarable {
60
+ return {
61
+ [DECLARABLE_MARKER]: true,
62
+ lexicon: "cedar",
63
+ entityType,
64
+ kind: "resource",
65
+ props,
66
+ } as unknown as Declarable;
67
+ }
68
+
69
+ function result(entities: Map<string, Declarable>): SerializerResult {
70
+ const out = cedarSerializer.serialize(entities);
71
+ if (typeof out === "string") throw new Error(`expected a SerializerResult, got: ${JSON.stringify(out)}`);
72
+ return out;
73
+ }
74
+
75
+ // ── The golden policy set ──────────────────────────────────────────
76
+
77
+ /**
78
+ * One inline macro library and four policies, each modelled on a shipped
79
+ * upstream example: approval-before-action, a `sum` budget, a `since`
80
+ * sequencing rule with a guardrail, and a `count` rate limit.
81
+ */
82
+ function goldenEntities(): Map<string, Declarable> {
83
+ const sumFormerly = defTemporalMacro(
84
+ "sum_formerly",
85
+ ["?a", "?w", "?body"],
86
+ sum(
87
+ "?a",
88
+ [typedBinder("?a", "Long"), typedBinder("$t", "Timepoint")],
89
+ formerly(macroWindow("?w"), and(macroCondition("?body"), tp("$t"))),
90
+ ),
91
+ "Sums the numeric value `?a` over occurrences of `?body` within window `?w`.",
92
+ );
93
+
94
+ return new Map<string, Declarable>([
95
+ ["library", mock(DOGWOOD_MACRO_LIBRARY_TYPE, { macros: [sumFormerly], inline: true })],
96
+ [
97
+ "readAfterLogin",
98
+ mock(DOGWOOD_POLICY_TYPE, {
99
+ annotations: { id: "read_after_login" },
100
+ action: { eq: 'Drupe::Action::"Read"' },
101
+ whenTemporal: [
102
+ formerly("1h", predicate('Drupe::Action::"Login"', "response", { "input.user": ctx("input.user") })),
103
+ ],
104
+ }),
105
+ ],
106
+ [
107
+ "transferSumOver100",
108
+ mock(DOGWOOD_POLICY_TYPE, {
109
+ annotations: { id: "transfer_sum_over_100" },
110
+ action: { eq: 'Drupe::Action::"Alert"' },
111
+ whenTemporal: [
112
+ exists(
113
+ typedBinder("total", "Long"),
114
+ and(
115
+ compare(
116
+ call("sum_formerly", [
117
+ varRef("a"),
118
+ interval("1h"),
119
+ predicate('Drupe::Action::"Transfer"', "request", {
120
+ "input.user": varRef("_"),
121
+ "input.amount": varRef("a"),
122
+ }),
123
+ ]),
124
+ "==",
125
+ varRef("total"),
126
+ ),
127
+ compare(varRef("total"), ">", 100),
128
+ ),
129
+ ),
130
+ ],
131
+ }),
132
+ ],
133
+ [
134
+ "noToolAfterSensitiveRead",
135
+ mock(DOGWOOD_POLICY_TYPE, {
136
+ effect: "forbid",
137
+ annotations: { id: "no_tool_after_sensitive_read" },
138
+ action: { eq: 'Drupe::Action::"Invoke"' },
139
+ whenGuardrails: ['context.input.tool != "audit"'],
140
+ whenTemporal: [
141
+ since(
142
+ predicate('Drupe::Action::"Read"', "response", { "output.classification": varRef("c") }),
143
+ "30m",
144
+ predicate('Drupe::Action::"Login"', "request"),
145
+ ),
146
+ ],
147
+ unless: ['principal in Drupe::Group::"breakglass"'],
148
+ }),
149
+ ],
150
+ [
151
+ "rateLimited",
152
+ mock(DOGWOOD_POLICY_TYPE, {
153
+ annotations: { id: "rate_limited" },
154
+ action: { eq: 'Drupe::Action::"Transfer"' },
155
+ whenTemporal: [
156
+ compare(
157
+ count(
158
+ [typedBinder("t", "Timepoint")],
159
+ formerly("15m", and(predicate('Drupe::Action::"Transfer"', "request"), tp("t"))),
160
+ ),
161
+ "<",
162
+ 5,
163
+ ),
164
+ ],
165
+ }),
166
+ ],
167
+ ]);
168
+ }
169
+
170
+ describe("the .dw golden output", () => {
171
+ test("matches the fixture byte for byte", () => {
172
+ const files = result(goldenEntities()).files ?? {};
173
+ expect(files[DOGWOOD_POLICY_FILENAME]).toBe(
174
+ fixture("temporal-policies.dw").slice(
175
+ attribution(
176
+ "dogwood-docs/examples/read_after_login/policy.dw",
177
+ "dogwood-docs/examples/temporal_sum_formerly_transfer/policy.dw",
178
+ "dogwood-docs/examples/read_heartbeat_since_login_30s/policy.dw",
179
+ "dogwood-docs/examples/temporal_count_formerly_login/policy.dw",
180
+ ).length,
181
+ ),
182
+ );
183
+ });
184
+
185
+ test("the head is Cedar's — annotations, effect, three scope positions", () => {
186
+ const text = result(goldenEntities()).files?.[DOGWOOD_POLICY_FILENAME] ?? "";
187
+ expect(text).toContain('@id("read_after_login")\npermit (\n principal,\n action == Drupe::Action::"Read",\n resource\n)');
188
+ expect(text).toContain('@id("no_tool_after_sensitive_read")\nforbid (');
189
+ });
190
+
191
+ test("inline macro definitions come before the policies that call them", () => {
192
+ const text = result(goldenEntities()).files?.[DOGWOOD_POLICY_FILENAME] ?? "";
193
+ expect(text.indexOf("def temporal sum_formerly")).toBeLessThan(text.indexOf("@id("));
194
+ });
195
+
196
+ test("every clause form the dialect adds is emitted", () => {
197
+ const text = result(goldenEntities()).files?.[DOGWOOD_POLICY_FILENAME] ?? "";
198
+ expect(text).toContain("when temporal {");
199
+ expect(text).toContain('when guardrails { context.input.tool != "audit" }');
200
+ expect(text).toContain('unless { principal in Drupe::Group::"breakglass" }');
201
+ });
202
+ });
203
+
204
+ // ── Ids and the cedar half ─────────────────────────────────────────
205
+
206
+ describe("the two halves of one policy set", () => {
207
+ const cedarPolicy = () =>
208
+ mock("Cedar::Policy", {
209
+ action: { eq: 'Drupe::Action::"List"' },
210
+ when: ["principal == resource.owner"],
211
+ });
212
+
213
+ test("a build with both emits both, in one pass", () => {
214
+ const entities = new Map<string, Declarable>([
215
+ ["allowOwnerList", cedarPolicy()],
216
+ [
217
+ "readAfterLogin",
218
+ mock(DOGWOOD_POLICY_TYPE, {
219
+ action: { eq: 'Drupe::Action::"Read"' },
220
+ whenTemporal: [formerly("1h", predicate('Drupe::Action::"Login"', "response"))],
221
+ }),
222
+ ],
223
+ ]);
224
+ const out = result(entities);
225
+ expect(out.primary).toContain('@id("allow-owner-list")');
226
+ expect(out.primary).not.toContain("temporal");
227
+ expect(Object.keys(out.files ?? {})).toContain(CEDAR_JSON_FILENAME);
228
+ expect(out.files?.[DOGWOOD_POLICY_FILENAME]).toContain('@id("read-after-login")');
229
+ });
230
+
231
+ test("policy ids are derived the same way on both legs", () => {
232
+ const entities = new Map<string, Declarable>([
233
+ [
234
+ "allowAdminRead",
235
+ mock(DOGWOOD_POLICY_TYPE, {
236
+ whenTemporal: [formerly("1h", predicate('Ns::Action::"A"', "request"))],
237
+ }),
238
+ ],
239
+ ]);
240
+ expect(result(entities).files?.[DOGWOOD_POLICY_FILENAME]).toContain('@id("allow-admin-read")');
241
+ });
242
+
243
+ test("a temporal-only build still emits its files, with an empty cedar primary", () => {
244
+ const entities = new Map<string, Declarable>([
245
+ [
246
+ "readAfterLogin",
247
+ mock(DOGWOOD_POLICY_TYPE, {
248
+ whenTemporal: [formerly("1h", predicate('Ns::Action::"A"', "request"))],
249
+ }),
250
+ ],
251
+ ]);
252
+ const out = result(entities);
253
+ expect(out.primary).toBe("");
254
+ expect(out.files?.[CEDAR_JSON_FILENAME]).toBeUndefined();
255
+ expect(out.files?.[DOGWOOD_POLICY_FILENAME]).toContain("when temporal {");
256
+ });
257
+
258
+ test("a cedar-only build is untouched by the dialect", () => {
259
+ const entities = new Map<string, Declarable>([["allowOwnerList", cedarPolicy()]]);
260
+ const out = result(entities);
261
+ expect(Object.keys(out.files ?? {})).toEqual([CEDAR_JSON_FILENAME]);
262
+ expect(out.warnings).toBeUndefined();
263
+ });
264
+
265
+ test("an empty build is still the empty string", () => {
266
+ expect(cedarSerializer.serialize(new Map())).toBe("");
267
+ });
268
+
269
+ test("the DWD id family is declared on the serializer", () => {
270
+ expect(cedarSerializer.extraRulePrefixes).toEqual(["DWD"]);
271
+ });
272
+ });
273
+
274
+ // ── The companion files ────────────────────────────────────────────
275
+
276
+ describe("event schemas and macro libraries", () => {
277
+ test("an event schema entity becomes .dwschema text", () => {
278
+ const entities = new Map<string, Declarable>([
279
+ ["events", mock(DOGWOOD_EVENT_SCHEMA_TYPE, { schema: defaultEventSchema() })],
280
+ ]);
281
+ const files = result(entities).files ?? {};
282
+ expect(files[DOGWOOD_EVENT_SCHEMA_FILENAME]).toContain("decision event <A>::request {");
283
+ });
284
+
285
+ test("an explicit filename is honoured, so several schemas can coexist", () => {
286
+ const entities = new Map<string, Declarable>([
287
+ ["a", mock(DOGWOOD_EVENT_SCHEMA_TYPE, { schema: defaultEventSchema() })],
288
+ ["b", mock(DOGWOOD_EVENT_SCHEMA_TYPE, { schema: defaultEventSchema({ maxWindow: "30d" }), filename: "gateway.dwschema" })],
289
+ ]);
290
+ const files = result(entities).files ?? {};
291
+ expect(files[DOGWOOD_EVENT_SCHEMA_FILENAME]).not.toContain("max_window");
292
+ expect(files["gateway.dwschema"]).toContain("max_window = 30d");
293
+ });
294
+
295
+ test("two schemas on one filename warn rather than emitting something upstream rejects", () => {
296
+ const entities = new Map<string, Declarable>([
297
+ ["a", mock(DOGWOOD_EVENT_SCHEMA_TYPE, { schema: defaultEventSchema() })],
298
+ ["b", mock(DOGWOOD_EVENT_SCHEMA_TYPE, { schema: defaultEventSchema({ maxWindow: "30d" }) })],
299
+ ]);
300
+ const out = result(entities);
301
+ expect(out.warnings?.[0]).toMatch(/both target events\.dwschema/);
302
+ expect(out.files?.[DOGWOOD_EVENT_SCHEMA_FILENAME]).not.toContain("max_window");
303
+ });
304
+
305
+ test("a non-inline macro library becomes its own file", () => {
306
+ const entities = new Map<string, Declarable>([
307
+ ["defaults", mock(DOGWOOD_MACRO_LIBRARY_TYPE, { macros: defaultMacroLibrary() })],
308
+ ]);
309
+ const files = result(entities).files ?? {};
310
+ expect(files[DOGWOOD_MACRO_FILENAME]).toBe(
311
+ fixture("default-macros.dw").slice(
312
+ attribution("dogwood-language/configuration/default_macros.dw").length,
313
+ ),
314
+ );
315
+ });
316
+
317
+ test("the emitted default library is upstream's, definition for definition", () => {
318
+ const text = renderMacroLibrary(defaultMacroLibrary());
319
+ for (const name of ["count_within", "sum_within", "count_distinct_within", "bind"]) {
320
+ expect(text).toContain(`def temporal ${name}(`);
321
+ }
322
+ });
323
+
324
+ test("an empty macro library warns rather than writing an empty file", () => {
325
+ const entities = new Map<string, Declarable>([
326
+ ["empty", mock(DOGWOOD_MACRO_LIBRARY_TYPE, { macros: [] })],
327
+ ]);
328
+ const out = cedarSerializer.serialize(entities);
329
+ expect(typeof out === "string" ? undefined : out.warnings?.[0]).toMatch(/declares no macros/);
330
+ });
331
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The `.dw` serializer leg.
3
+ *
4
+ * Runs inside the cedar serializer, not beside it: `serializeDogwood` is
5
+ * called once the cedar policies have been rendered, and its files land in the
6
+ * same `SerializerResult`. That is the whole point of shipping dogwood as a
7
+ * dialect — a `.dw` file stripped of Cedar semantics is meaningless, and a
8
+ * project's temporal policies and its plain Cedar policies are one policy set
9
+ * with two halves.
10
+ *
11
+ * Nothing here re-implements Cedar rendering. The policy head comes from
12
+ * `../serializer`'s `renderPolicyHead`, the scope forms from its
13
+ * `renderScope`, and the `when`/`unless` bodies from its `conditionStrings`,
14
+ * so a change to how a scope is written moves both outputs at once.
15
+ */
16
+
17
+ import type { Declarable } from "@intentius/chant/declarable";
18
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
19
+ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
20
+ import { conditionStrings, getProps, isRecord, renderPolicyHead, resolvePolicyId } from "../policy-text";
21
+ import { renderCondition, type TemporalCondition } from "./temporal";
22
+ import { renderEventSchema, type EventSchema } from "./event-schema";
23
+ import { renderMacroLibrary, type MacroDefinition } from "./macros";
24
+ import {
25
+ DOGWOOD_EVENT_SCHEMA_FILENAME,
26
+ DOGWOOD_EVENT_SCHEMA_TYPE,
27
+ DOGWOOD_MACRO_FILENAME,
28
+ DOGWOOD_MACRO_LIBRARY_TYPE,
29
+ DOGWOOD_POLICY_FILENAME,
30
+ DOGWOOD_POLICY_TYPE,
31
+ } from "./policy";
32
+
33
+ /** Files ending in this carry dogwood policy text. */
34
+ export const DOGWOOD_POLICY_SUFFIX = ".dw";
35
+
36
+ /** Files ending in this carry an event schema. */
37
+ export const DOGWOOD_EVENT_SCHEMA_SUFFIX = ".dwschema";
38
+
39
+ /** What the leg produced: files to write, and anything the build should say out loud. */
40
+ export interface DogwoodSerializeResult {
41
+ files: Record<string, string>;
42
+ warnings: string[];
43
+ }
44
+
45
+ // ── Clause rendering ──────────────────────────────────────────────
46
+
47
+ function temporalConditions(value: unknown): TemporalCondition[] {
48
+ if (value === undefined || value === null) return [];
49
+ const list = Array.isArray(value) ? value : [value];
50
+ return list.filter((node): node is TemporalCondition => isRecord(node) && typeof node.op === "string");
51
+ }
52
+
53
+ /**
54
+ * One policy, in `.dw` surface syntax.
55
+ *
56
+ * Clause order is fixed rather than authored: every `when` form, then every
57
+ * `unless` form. Cedar evaluates a policy's conditions as a conjunction, and
58
+ * so does dogwood, so the order carries no meaning — fixing it means a policy
59
+ * that gains a temporal clause does not reshuffle the ones already there.
60
+ */
61
+ export function renderTemporalPolicyText(id: string, props: Record<string, unknown>): string {
62
+ const lines = renderPolicyHead(id, props);
63
+
64
+ for (const clause of conditionStrings(props.when)) lines.push(`when { ${clause} }`);
65
+ for (const clause of conditionStrings(props.whenGuardrails)) lines.push(`when guardrails { ${clause} }`);
66
+ for (const node of temporalConditions(props.whenTemporal)) {
67
+ lines.push("when temporal {", ` ${renderCondition(node)}`, "}");
68
+ }
69
+
70
+ for (const clause of conditionStrings(props.unless)) lines.push(`unless { ${clause} }`);
71
+ for (const clause of conditionStrings(props.unlessGuardrails)) lines.push(`unless guardrails { ${clause} }`);
72
+ for (const node of temporalConditions(props.unlessTemporal)) {
73
+ lines.push("unless temporal {", ` ${renderCondition(node)}`, "}");
74
+ }
75
+
76
+ return lines.join("\n") + ";";
77
+ }
78
+
79
+ // ── The walker ────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Same visitor contract as the cedar serializer's: a reference collapses to
83
+ * the text a policy names it by. The temporal AST is plain data, so the walker
84
+ * passes it through untouched while still resolving any Declarable a scope
85
+ * position holds.
86
+ */
87
+ const dogwoodVisitor: SerializerVisitor = {
88
+ attrRef: (logicalName, attribute) => `${logicalName}.${attribute}`,
89
+ resourceRef: (logicalName) => logicalName,
90
+ propertyDeclarable: (entity, walk) => {
91
+ const out: Record<string, unknown> = {};
92
+ for (const [key, value] of Object.entries(getProps(entity))) {
93
+ if (key === "entityType" || key === "lexicon") continue;
94
+ if (value === undefined) continue;
95
+ out[key] = walk(value);
96
+ }
97
+ return out;
98
+ },
99
+ };
100
+
101
+ // ── The leg ───────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Render every dogwood entity in the build.
105
+ *
106
+ * Returns `undefined` when the build holds no temporal policies, event schemas
107
+ * or macro libraries at all — a cedar-only project sees no `.dw` output and no
108
+ * behaviour change.
109
+ *
110
+ * Ids come from `resolvePolicyId`, the same rule `cedarPolicyRecords` uses, so
111
+ * a policy's `@id` does not depend on which leg rendered it.
112
+ */
113
+ export function serializeDogwood(entities: Map<string, Declarable>): DogwoodSerializeResult | undefined {
114
+ // Reverse map first: walkValue resolves a Declarable reference by identity,
115
+ // so every entity has to be known before any of them is walked.
116
+ const entityNames = new Map<Declarable, string>();
117
+ for (const [name, entity] of entities) {
118
+ entityNames.set(entity, name);
119
+ }
120
+
121
+ const policyText: string[] = [];
122
+ const inlineMacros: MacroDefinition[] = [];
123
+ const macroFiles = new Map<string, MacroDefinition[]>();
124
+ const schemaFiles = new Map<string, { name: string; schema: EventSchema }>();
125
+ const warnings: string[] = [];
126
+
127
+ for (const [name, entity] of entities) {
128
+ if (isPropertyDeclarable(entity)) continue;
129
+ if (
130
+ entity.entityType !== DOGWOOD_POLICY_TYPE &&
131
+ entity.entityType !== DOGWOOD_EVENT_SCHEMA_TYPE &&
132
+ entity.entityType !== DOGWOOD_MACRO_LIBRARY_TYPE
133
+ ) {
134
+ continue;
135
+ }
136
+
137
+ const props = walkValue(getProps(entity), entityNames, dogwoodVisitor) as Record<string, unknown>;
138
+
139
+ if (entity.entityType === DOGWOOD_POLICY_TYPE) {
140
+ policyText.push(renderTemporalPolicyText(resolvePolicyId(name, props), props));
141
+ continue;
142
+ }
143
+
144
+ if (entity.entityType === DOGWOOD_MACRO_LIBRARY_TYPE) {
145
+ const defs = Array.isArray(props.macros) ? (props.macros as MacroDefinition[]) : [];
146
+ if (defs.length === 0) {
147
+ warnings.push(`dogwood: macro library "${name}" declares no macros, so nothing was emitted for it`);
148
+ continue;
149
+ }
150
+ if (props.inline === true) {
151
+ inlineMacros.push(...defs);
152
+ continue;
153
+ }
154
+ const filename = typeof props.filename === "string" ? props.filename : DOGWOOD_MACRO_FILENAME;
155
+ macroFiles.set(filename, [...(macroFiles.get(filename) ?? []), ...defs]);
156
+ continue;
157
+ }
158
+
159
+ // DOGWOOD_EVENT_SCHEMA_TYPE
160
+ const schema = props.schema;
161
+ if (!isRecord(schema) || !Array.isArray(schema.events)) {
162
+ warnings.push(`dogwood: event schema "${name}" has no \`schema\` prop, so no .dwschema was written for it`);
163
+ continue;
164
+ }
165
+ const filename = typeof props.filename === "string" ? props.filename : DOGWOOD_EVENT_SCHEMA_FILENAME;
166
+ const existing = schemaFiles.get(filename);
167
+ if (existing) {
168
+ // Two schemas cannot share a file: `max_window` is a single directive at
169
+ // the top, so concatenating them would emit something upstream rejects.
170
+ warnings.push(
171
+ `dogwood: event schemas "${existing.name}" and "${name}" both target ${filename}; only "${existing.name}" was written — give one of them an explicit \`filename\``,
172
+ );
173
+ continue;
174
+ }
175
+ schemaFiles.set(filename, { name, schema: schema as unknown as EventSchema });
176
+ }
177
+
178
+ // Warnings alone are enough to report: a dogwood entity that produced no
179
+ // file is exactly the case worth saying out loud, and returning `undefined`
180
+ // here would swallow the reason.
181
+ if (
182
+ policyText.length === 0 &&
183
+ inlineMacros.length === 0 &&
184
+ macroFiles.size === 0 &&
185
+ schemaFiles.size === 0 &&
186
+ warnings.length === 0
187
+ ) {
188
+ return undefined;
189
+ }
190
+
191
+ const files: Record<string, string> = {};
192
+
193
+ if (policyText.length > 0 || inlineMacros.length > 0) {
194
+ const sections: string[] = [];
195
+ if (inlineMacros.length > 0) sections.push(renderMacroLibrary(inlineMacros).trimEnd());
196
+ sections.push(...policyText);
197
+ files[DOGWOOD_POLICY_FILENAME] = sections.join("\n\n") + "\n";
198
+ }
199
+
200
+ for (const [filename, defs] of macroFiles) {
201
+ files[filename] = renderMacroLibrary(defs);
202
+ }
203
+
204
+ for (const [filename, { schema }] of schemaFiles) {
205
+ files[filename] = renderEventSchema(schema);
206
+ }
207
+
208
+ return { files, warnings };
209
+ }