@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,545 @@
1
+ /**
2
+ * The `dogwood` CLI adapter (#1659, epic #1646).
3
+ *
4
+ * The epic splits validation by what needs a binary. Everything answerable in
5
+ * TypeScript — the DWDC walls in `../lint/post-synth/dwdc0*.ts` — runs always
6
+ * and gates. Full `.dw` validation needs upstream's own frontend, which ships
7
+ * only as a Rust CLI: no npm package, no wasm build, nothing to link against.
8
+ * This module is the seam to it, and everything downstream of it treats the
9
+ * binary as optional.
10
+ *
11
+ * Five things about that CLI decide how this is written, all from the #1657
12
+ * verification report and re-read from the pinned `dogwood-cli/src` sources:
13
+ *
14
+ * 1. **Exit codes do not identify the failure.** Exit 2 is both "the policy
15
+ * set was rejected" and clap's own usage error for an unknown flag, and the
16
+ * published guide claims 1 for the latter. So nothing here branches on the
17
+ * exit code: the JSON on stdout decides, and a run that produced no JSON is
18
+ * {@link DogwoodUnusable} — reported as "the CLI could not be used", never
19
+ * as "your policy is bad".
20
+ * 2. **There are two JSON shapes, not one.** A type-check finding comes back
21
+ * in a `ValidateReport` (`{passed, passed_without_warnings, errors[],
22
+ * warnings[]}`); a fatal parse, macro or lowering error replaces the whole
23
+ * report with a bare `OpError` — the same diagnostic fields flattened at the
24
+ * top level, plus `related[]`. {@link parseValidateOutput} handles both.
25
+ * 3. **`--format json` writes to stdout for both channels**, success and
26
+ * fatal alike, and human-mode errors go to stderr. Every call here passes
27
+ * `--format json` and reads stdout; stderr is kept only to explain an
28
+ * unusable run.
29
+ * 4. **`--emit` is ignored under `--format json`** — the JSON always carries
30
+ * all three lowered artifacts — so {@link runDogwoodLower} does not pass it.
31
+ * 5. **Label offsets are bytes into the source**, not line/column. They are
32
+ * rendered as byte ranges. The fatal channel's own internal positions are
33
+ * never passed through verbatim, for the same reason `wasm-helpers.ts`
34
+ * strips cedar-wasm's `at line 1 column 42`: they locate something in a
35
+ * representation the reader never saw.
36
+ *
37
+ * The runner is injectable ({@link configureDogwoodCli}) so the checks are
38
+ * testable without the binary. Nothing in gating CI executes it.
39
+ */
40
+
41
+ import { spawnSync } from "child_process";
42
+ import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
43
+ import { tmpdir } from "os";
44
+ import { delimiter, dirname, isAbsolute, join, resolve } from "path";
45
+
46
+ // ── Locating the binary ───────────────────────────────────────────
47
+
48
+ /** The executable name looked up on `PATH`. */
49
+ export const DOGWOOD_BINARY_NAME = "dogwood";
50
+
51
+ /** Environment override, read before `PATH` and after an explicit config. */
52
+ export const DOGWOOD_BINARY_ENV = "CHANT_DOGWOOD_BINARY";
53
+
54
+ /** Where a resolved binary came from — carried so a finding can say. */
55
+ export type DogwoodBinarySource = "override" | "env" | "config" | "path";
56
+
57
+ /** A located `dogwood` executable. */
58
+ export interface DogwoodBinary {
59
+ /** Absolute path, or the configured path as written. */
60
+ path: string;
61
+ source: DogwoodBinarySource;
62
+ }
63
+
64
+ /**
65
+ * One process run, reduced to what the adapter reads.
66
+ *
67
+ * `error` is set when the process never ran at all (ENOENT, a permission
68
+ * failure, a timeout) — distinct from a run that exited non-zero.
69
+ */
70
+ export interface DogwoodRun {
71
+ status: number | null;
72
+ stdout: string;
73
+ stderr: string;
74
+ error?: string;
75
+ }
76
+
77
+ /** Runs `binary args…` and returns the captured streams. Injectable for tests. */
78
+ export type DogwoodRunner = (binary: string, args: string[]) => DogwoodRun;
79
+
80
+ interface CliDefaults {
81
+ /** A path forces that binary; `null` forces "absent"; `undefined` resolves normally. */
82
+ binary?: string | null;
83
+ runner?: DogwoodRunner;
84
+ }
85
+
86
+ let defaults: CliDefaults = {};
87
+
88
+ /**
89
+ * Override binary resolution and the process runner.
90
+ *
91
+ * Two callers. Tests inject a runner so no gating check ever depends on a real
92
+ * binary — including on a developer's machine that happens to have one, which
93
+ * is why `binary: null` (force-absent) exists as well as `binary: "/path"`.
94
+ * The on-demand harness — the `forgejo-runtime-e2e` shape the epic names — is
95
+ * the other: it knows where it built the binary and says so directly rather
96
+ * than arranging `PATH`.
97
+ */
98
+ export function configureDogwoodCli(options: CliDefaults): void {
99
+ defaults = { ...defaults, ...options };
100
+ }
101
+
102
+ /** Drop every override, including any cached resolution. Test-only. */
103
+ export function resetDogwoodCli(): void {
104
+ defaults = {};
105
+ cachedConfigBinary = undefined;
106
+ }
107
+
108
+ let cachedConfigBinary: { dir: string; value: string | undefined } | undefined;
109
+
110
+ /**
111
+ * `cedar.dogwood.binary` out of a `chant.config.json`, walking up from `dir`.
112
+ *
113
+ * JSON only, and that is a real limitation rather than an oversight. A
114
+ * `PostSynthCheck.check()` is synchronous — see `@intentius/chant/lint/post-synth`
115
+ * — while `loadChantConfigUpward` is async and, under `chant build --sandbox`,
116
+ * evaluates `chant.config.ts` in a child process. Neither is available from
117
+ * inside a check. `chant.config.json` is data, not code (core says so in
118
+ * `loadChantConfig`'s own doc), so reading it here executes nothing. A project
119
+ * on `chant.config.ts` sets {@link DOGWOOD_BINARY_ENV} or calls
120
+ * {@link configureDogwoodCli} instead.
121
+ */
122
+ function configuredBinary(startDir: string): string | undefined {
123
+ if (cachedConfigBinary && cachedConfigBinary.dir === startDir) return cachedConfigBinary.value;
124
+
125
+ let value: string | undefined;
126
+ let dir = startDir;
127
+ for (;;) {
128
+ const path = join(dir, "chant.config.json");
129
+ if (existsSync(path)) {
130
+ try {
131
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
132
+ const binary = readPath(parsed, ["cedar", "dogwood", "binary"]);
133
+ if (typeof binary === "string" && binary.length > 0) {
134
+ value = isAbsolute(binary) ? binary : resolve(dir, binary);
135
+ break;
136
+ }
137
+ } catch {
138
+ // An unreadable or malformed config is every other command's error to
139
+ // report first; a missing dogwood binary is not the place to raise it.
140
+ }
141
+ }
142
+ const parent = dirname(dir);
143
+ if (parent === dir) break;
144
+ dir = parent;
145
+ }
146
+
147
+ cachedConfigBinary = { dir: startDir, value };
148
+ return value;
149
+ }
150
+
151
+ function readPath(value: unknown, path: string[]): unknown {
152
+ let current = value;
153
+ for (const key of path) {
154
+ if (typeof current !== "object" || current === null) return undefined;
155
+ current = (current as Record<string, unknown>)[key];
156
+ }
157
+ return current;
158
+ }
159
+
160
+ function executable(path: string): boolean {
161
+ try {
162
+ accessSync(path, constants.X_OK);
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /** The first `dogwood` on `PATH`, by an access check rather than by running it. */
170
+ function onPath(name: string): string | undefined {
171
+ for (const entry of (process.env.PATH ?? "").split(delimiter)) {
172
+ if (!entry) continue;
173
+ const candidate = join(entry, name);
174
+ if (executable(candidate)) return candidate;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * Locate the `dogwood` binary, or `undefined` when there is none.
181
+ *
182
+ * Order: an explicit {@link configureDogwoodCli} override, `$CHANT_DOGWOOD_BINARY`,
183
+ * `cedar.dogwood.binary` from a `chant.config.json`, then `PATH`. A path from
184
+ * the environment or the config that is not executable is resolved past rather
185
+ * than returned to fail later; the advisory that follows names where chant
186
+ * looked. An explicit override is taken as given — its caller knows.
187
+ */
188
+ export function findDogwoodBinary(cwd: string = process.cwd()): DogwoodBinary | undefined {
189
+ if (defaults.binary === null) return undefined;
190
+ if (typeof defaults.binary === "string") return { path: defaults.binary, source: "override" };
191
+
192
+ const fromEnv = process.env[DOGWOOD_BINARY_ENV];
193
+ if (fromEnv && executable(fromEnv)) return { path: fromEnv, source: "env" };
194
+
195
+ const fromConfig = configuredBinary(cwd);
196
+ if (fromConfig && executable(fromConfig)) return { path: fromConfig, source: "config" };
197
+
198
+ const found = onPath(DOGWOOD_BINARY_NAME);
199
+ return found ? { path: found, source: "path" } : undefined;
200
+ }
201
+
202
+ /** Where chant looked, for an advisory that has to be actionable. */
203
+ export const DOGWOOD_SEARCH_ORDER = `$${DOGWOOD_BINARY_ENV}, then cedar.dogwood.binary in chant.config.json, then \`${DOGWOOD_BINARY_NAME}\` on PATH`;
204
+
205
+ // ── The JSON contract ─────────────────────────────────────────────
206
+
207
+ /** One labelled span: byte offsets into the source the CLI was handed. */
208
+ export interface DogwoodLabel {
209
+ start: number;
210
+ len: number;
211
+ message?: string;
212
+ }
213
+
214
+ /**
215
+ * One diagnostic, in upstream's `Diagnostic` shape.
216
+ *
217
+ * The same object is the element type of a report's `errors[]`/`warnings[]`
218
+ * and the whole body of a fatal `OpError`, which is why both channels
219
+ * normalize into this and nothing downstream has to know which arrived.
220
+ */
221
+ export interface DogwoodDiagnostic {
222
+ /** `error`, `warning` or `advice` — upstream lowercases miette's severity. */
223
+ severity: string;
224
+ /** A stable code such as `extension` or `cedar`, when the finding has one. */
225
+ code?: string;
226
+ message: string;
227
+ labels: DogwoodLabel[];
228
+ help?: string;
229
+ /** False when the finding has no source-anchored span at all. */
230
+ spanned: boolean;
231
+ }
232
+
233
+ function isRecord(value: unknown): value is Record<string, unknown> {
234
+ return typeof value === "object" && value !== null && !Array.isArray(value);
235
+ }
236
+
237
+ /**
238
+ * Strip an internal `at line N column M` tail.
239
+ *
240
+ * Same reasoning as `wasm-helpers.ts`'s `call()`: a position that indexes a
241
+ * representation the reader never saw is worse than no position, because it
242
+ * looks like it points at their policy. The byte-offset labels are the
243
+ * positions this module does surface, and those genuinely index the `.dw`
244
+ * source chant wrote.
245
+ */
246
+ function scrubPosition(message: string): string {
247
+ return message.replace(/\s+at line \d+ column \d+\.?$/, "");
248
+ }
249
+
250
+ function parseDiagnostic(value: unknown): DogwoodDiagnostic | undefined {
251
+ if (!isRecord(value) || typeof value.message !== "string") return undefined;
252
+ const labels: DogwoodLabel[] = [];
253
+ if (Array.isArray(value.labels)) {
254
+ for (const raw of value.labels) {
255
+ if (!isRecord(raw) || typeof raw.start !== "number" || typeof raw.len !== "number") continue;
256
+ labels.push({
257
+ start: raw.start,
258
+ len: raw.len,
259
+ ...(typeof raw.message === "string" ? { message: raw.message } : {}),
260
+ });
261
+ }
262
+ }
263
+ return {
264
+ severity: typeof value.severity === "string" ? value.severity : "error",
265
+ ...(typeof value.code === "string" ? { code: value.code } : {}),
266
+ message: scrubPosition(value.message),
267
+ labels,
268
+ ...(typeof value.help === "string" ? { help: value.help } : {}),
269
+ spanned: value.spanned === true || labels.length > 0,
270
+ };
271
+ }
272
+
273
+ function parseDiagnostics(value: unknown): DogwoodDiagnostic[] {
274
+ if (!Array.isArray(value)) return [];
275
+ const out: DogwoodDiagnostic[] = [];
276
+ for (const entry of value) {
277
+ const parsed = parseDiagnostic(entry);
278
+ if (parsed) out.push(parsed);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * One diagnostic as a single line: code, message, help, then the byte ranges.
285
+ *
286
+ * Byte offsets rather than line/column because that is what upstream reports
287
+ * and converting them would mean re-deriving line breaks over a file this
288
+ * module does not hold — a wrong line number is worse than an honest offset.
289
+ */
290
+ export function formatDogwoodDiagnostic(diagnostic: DogwoodDiagnostic): string {
291
+ const parts: string[] = [];
292
+ if (diagnostic.code) parts.push(`[${diagnostic.code}]`);
293
+ parts.push(diagnostic.message);
294
+ if (diagnostic.help) parts.push(`(${diagnostic.help})`);
295
+ if (diagnostic.labels.length > 0) {
296
+ const spans = diagnostic.labels
297
+ .map((l) => {
298
+ const range = `bytes ${l.start}-${l.start + l.len}`;
299
+ return l.message ? `${range}: ${l.message}` : range;
300
+ })
301
+ .join("; ");
302
+ parts.push(`at ${spans}`);
303
+ }
304
+ return parts.join(" ");
305
+ }
306
+
307
+ // ── Results ───────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * The CLI could not be used — a spawn failure, output that is not the JSON
311
+ * this adapter knows, or the exit-2 ambiguity resolving to neither shape.
312
+ *
313
+ * Deliberately its own arm and never folded into "rejected": exit 2 covers an
314
+ * unknown flag as well as a rejected policy set, so treating a bare non-zero
315
+ * exit as a policy verdict would fail a build over a flag rename in a sync
316
+ * upstream can make without notice.
317
+ */
318
+ export interface DogwoodUnusable {
319
+ kind: "unusable";
320
+ reason: string;
321
+ }
322
+
323
+ /** A fatal parse, macro or lowering error — upstream's `OpError` channel. */
324
+ export interface DogwoodFatal {
325
+ kind: "fatal";
326
+ error: DogwoodDiagnostic;
327
+ related: DogwoodDiagnostic[];
328
+ }
329
+
330
+ export type DogwoodValidateResult =
331
+ | { kind: "passed"; warnings: DogwoodDiagnostic[] }
332
+ | { kind: "rejected"; errors: DogwoodDiagnostic[]; warnings: DogwoodDiagnostic[] }
333
+ | DogwoodFatal
334
+ | DogwoodUnusable;
335
+
336
+ /** The `lower` artifacts. `cedarSchemaJson` is a JSON *string*, as upstream serializes it. */
337
+ export interface DogwoodLowered {
338
+ cedarPolicies: string;
339
+ cedarSchema: string;
340
+ cedarSchemaJson: string;
341
+ /** False when temporal or provider fields were hoisted — the usual case. */
342
+ selfContained: boolean;
343
+ temporalFields: string[];
344
+ providerFields: string[];
345
+ decisionKinds: string[];
346
+ }
347
+
348
+ export type DogwoodLowerResult = { kind: "lowered"; value: DogwoodLowered } | DogwoodFatal | DogwoodUnusable;
349
+
350
+ function unusable(reason: string): DogwoodUnusable {
351
+ return { kind: "unusable", reason };
352
+ }
353
+
354
+ /** A short, single-line tail of whatever the process said, for an unusable run. */
355
+ function streamNote(run: DogwoodRun): string {
356
+ const text = (run.stderr || run.stdout).trim().split("\n").slice(0, 2).join(" ").trim();
357
+ const clipped = text.length > 200 ? `${text.slice(0, 200)}…` : text;
358
+ return clipped ? `: ${clipped}` : "";
359
+ }
360
+
361
+ function fatalFrom(parsed: Record<string, unknown>): DogwoodFatal | undefined {
362
+ const error = parseDiagnostic(parsed);
363
+ if (!error) return undefined;
364
+ return { kind: "fatal", error, related: parseDiagnostics(parsed.related) };
365
+ }
366
+
367
+ /**
368
+ * Read a `validate --format json` run.
369
+ *
370
+ * The JSON decides, not the exit code. A body carrying `passed` is the report
371
+ * shape; a body carrying `message` and no `passed` is the fatal `OpError`
372
+ * shape; anything else means the invocation itself was rejected — which is
373
+ * where clap's exit-2 usage error lands, with its complaint on stderr and
374
+ * nothing on stdout.
375
+ */
376
+ export function parseValidateOutput(run: DogwoodRun): DogwoodValidateResult {
377
+ if (run.error) return unusable(`the dogwood binary could not be run: ${run.error}`);
378
+
379
+ const parsed = readJson(run.stdout);
380
+ if (!parsed) {
381
+ return unusable(
382
+ `\`dogwood validate --format json\` exited ${String(run.status)} without a JSON report on stdout, which is how an unknown flag or a rejected invocation surfaces${streamNote(run)}`,
383
+ );
384
+ }
385
+
386
+ if (typeof parsed.passed === "boolean") {
387
+ const errors = parseDiagnostics(parsed.errors);
388
+ const warnings = parseDiagnostics(parsed.warnings);
389
+ // The report wins over the exit code in both directions: a `passed: false`
390
+ // with a zero exit is still a rejection, and the reverse is still a pass.
391
+ if (parsed.passed && errors.length === 0) return { kind: "passed", warnings };
392
+ return { kind: "rejected", errors, warnings };
393
+ }
394
+
395
+ const fatal = fatalFrom(parsed);
396
+ if (fatal) return fatal;
397
+
398
+ return unusable(`\`dogwood validate --format json\` returned JSON in neither the report nor the error shape`);
399
+ }
400
+
401
+ /** Read a `lower --format json` run. Same two-shape discipline as validate. */
402
+ export function parseLowerOutput(run: DogwoodRun): DogwoodLowerResult {
403
+ if (run.error) return unusable(`the dogwood binary could not be run: ${run.error}`);
404
+
405
+ const parsed = readJson(run.stdout);
406
+ if (!parsed) {
407
+ return unusable(
408
+ `\`dogwood lower --format json\` exited ${String(run.status)} without JSON on stdout${streamNote(run)}`,
409
+ );
410
+ }
411
+
412
+ if (typeof parsed.cedar_policies === "string" && typeof parsed.cedar_schema === "string") {
413
+ return {
414
+ kind: "lowered",
415
+ value: {
416
+ cedarPolicies: parsed.cedar_policies,
417
+ cedarSchema: parsed.cedar_schema,
418
+ cedarSchemaJson: typeof parsed.cedar_schema_json === "string" ? parsed.cedar_schema_json : "",
419
+ selfContained: parsed.self_contained === true,
420
+ temporalFields: stringList(parsed.temporal_fields),
421
+ providerFields: stringList(parsed.provider_fields),
422
+ decisionKinds: stringList(parsed.decision_kinds),
423
+ },
424
+ };
425
+ }
426
+
427
+ const fatal = fatalFrom(parsed);
428
+ if (fatal) return fatal;
429
+
430
+ return unusable("`dogwood lower --format json` returned JSON in neither the artifact nor the error shape");
431
+ }
432
+
433
+ function stringList(value: unknown): string[] {
434
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
435
+ }
436
+
437
+ function readJson(text: string): Record<string, unknown> | undefined {
438
+ const trimmed = text.trim();
439
+ if (!trimmed) return undefined;
440
+ try {
441
+ const parsed: unknown = JSON.parse(trimmed);
442
+ return isRecord(parsed) ? parsed : undefined;
443
+ } catch {
444
+ return undefined;
445
+ }
446
+ }
447
+
448
+ // ── Invocation ────────────────────────────────────────────────────
449
+
450
+ /**
451
+ * The files one CLI invocation needs.
452
+ *
453
+ * `policies` and `policySchema` are both required — every pipeline command
454
+ * takes the policy set positionally and `--policy-schema` is not optional. The
455
+ * rest are the service half of the schema, each defaulting at the far end.
456
+ */
457
+ export interface DogwoodBundle {
458
+ /** `.dw` policy set text. */
459
+ policies: string;
460
+ /** Cedar action schema text (`--policy-schema`). */
461
+ policySchema: string;
462
+ /** `.dwschema` event-schema text (`--event-schema`). */
463
+ eventSchema?: string;
464
+ /** `.dw` macro library text (`--macros`). */
465
+ macros?: string;
466
+ /** `providers.json` text (`--providers`). Rhai must be inlined under `implementation.script`. */
467
+ providers?: string;
468
+ }
469
+
470
+ const defaultRunner: DogwoodRunner = (binary, args) => {
471
+ const result = spawnSync(binary, args, {
472
+ encoding: "utf-8",
473
+ timeout: 60_000,
474
+ maxBuffer: 32 * 1024 * 1024,
475
+ });
476
+ return {
477
+ status: result.status,
478
+ stdout: result.stdout ?? "",
479
+ stderr: result.stderr ?? "",
480
+ ...(result.error ? { error: result.error.message } : {}),
481
+ };
482
+ };
483
+
484
+ /**
485
+ * Materialize a bundle into a scratch directory and run one subcommand.
486
+ *
487
+ * Files rather than stdin because only the policy set can come from `-`; the
488
+ * schema flags all take paths. The directory is removed in a `finally`, so a
489
+ * throwing runner does not leave one behind.
490
+ */
491
+ function runBundle(binary: string, subcommand: string, bundle: DogwoodBundle): DogwoodRun {
492
+ const runner = defaults.runner ?? defaultRunner;
493
+ const dir = mkdtempSync(join(tmpdir(), "chant-dogwood-"));
494
+ try {
495
+ const policies = join(dir, "policies.dw");
496
+ const policySchema = join(dir, "schema.cedarschema");
497
+ writeFileSync(policies, bundle.policies, "utf-8");
498
+ writeFileSync(policySchema, bundle.policySchema, "utf-8");
499
+
500
+ const args = [subcommand, policies, "--policy-schema", policySchema];
501
+
502
+ if (bundle.eventSchema !== undefined) {
503
+ const path = join(dir, "events.dwschema");
504
+ writeFileSync(path, bundle.eventSchema, "utf-8");
505
+ args.push("--event-schema", path);
506
+ }
507
+ if (bundle.macros !== undefined) {
508
+ const path = join(dir, "macros.dw");
509
+ writeFileSync(path, bundle.macros, "utf-8");
510
+ args.push("--macros", path);
511
+ }
512
+ if (bundle.providers !== undefined) {
513
+ const path = join(dir, "providers.json");
514
+ writeFileSync(path, bundle.providers, "utf-8");
515
+ args.push("--providers", path);
516
+ }
517
+
518
+ // No `--emit`: it is ignored under `--format json`, which returns all three
519
+ // lowered artifacts regardless. Passing it would imply a choice that the
520
+ // CLI does not honour.
521
+ args.push("--format", "json");
522
+
523
+ return runner(binary, args);
524
+ } finally {
525
+ rmSync(dir, { recursive: true, force: true });
526
+ }
527
+ }
528
+
529
+ /** Run `dogwood validate` over a bundle. Never throws. */
530
+ export function runDogwoodValidate(binary: string, bundle: DogwoodBundle): DogwoodValidateResult {
531
+ try {
532
+ return parseValidateOutput(runBundle(binary, "validate", bundle));
533
+ } catch (err) {
534
+ return unusable(`the dogwood validate run failed: ${err instanceof Error ? err.message : String(err)}`);
535
+ }
536
+ }
537
+
538
+ /** Run `dogwood lower` over a bundle. Never throws. */
539
+ export function runDogwoodLower(binary: string, bundle: DogwoodBundle): DogwoodLowerResult {
540
+ try {
541
+ return parseLowerOutput(runBundle(binary, "lower", bundle));
542
+ } catch (err) {
543
+ return unusable(`the dogwood lower run failed: ${err instanceof Error ? err.message : String(err)}`);
544
+ }
545
+ }