@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.
- package/README.md +58 -0
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/dogwood/cli.d.ts +206 -0
- package/dist/dogwood/cli.d.ts.map +1 -0
- package/dist/dogwood/event-schema.d.ts +161 -0
- package/dist/dogwood/event-schema.d.ts.map +1 -0
- package/dist/dogwood/index.d.ts +33 -0
- package/dist/dogwood/index.d.ts.map +1 -0
- package/dist/dogwood/macros.d.ts +96 -0
- package/dist/dogwood/macros.d.ts.map +1 -0
- package/dist/dogwood/policy.d.ts +120 -0
- package/dist/dogwood/policy.d.ts.map +1 -0
- package/dist/dogwood/scan.d.ts +109 -0
- package/dist/dogwood/scan.d.ts.map +1 -0
- package/dist/dogwood/serialize.d.ts +46 -0
- package/dist/dogwood/serialize.d.ts.map +1 -0
- package/dist/dogwood/temporal.d.ts +259 -0
- package/dist/dogwood/temporal.d.ts.map +1 -0
- package/dist/dogwood/upstream.d.ts +41 -0
- package/dist/dogwood/upstream.d.ts.map +1 -0
- package/dist/dogwood/window.d.ts +73 -0
- package/dist/dogwood/window.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +13 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
- package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc010.d.ts +25 -0
- package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc011.d.ts +19 -0
- package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc012.d.ts +21 -0
- package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde010.d.ts +32 -0
- package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde011.d.ts +33 -0
- package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwds010.d.ts +24 -0
- package/dist/lint/post-synth/dwds010.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +6 -0
- package/dist/okf/rules/DWDC010.md +11 -0
- package/dist/okf/rules/DWDC011.md +11 -0
- package/dist/okf/rules/DWDC012.md +11 -0
- package/dist/okf/rules/DWDE010.md +11 -0
- package/dist/okf/rules/DWDE011.md +11 -0
- package/dist/okf/rules/DWDS010.md +11 -0
- package/dist/policy-text.d.ts +53 -0
- package/dist/policy-text.d.ts.map +1 -0
- package/dist/rules/dogwood-helpers.ts +139 -0
- package/dist/rules/dwdc010.ts +62 -0
- package/dist/rules/dwdc011.ts +61 -0
- package/dist/rules/dwdc012.ts +46 -0
- package/dist/rules/dwde010.ts +130 -0
- package/dist/rules/dwde011.ts +108 -0
- package/dist/rules/dwds010.ts +46 -0
- package/dist/serializer.d.ts +10 -18
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-cedar-authoring.md +180 -0
- package/dist/skills/chant-cedar-avp-embedding.md +125 -0
- package/dist/skills/chant-cedar-meta-policy.md +119 -0
- package/package.json +2 -2
- package/src/codegen/package.ts +3 -2
- package/src/config.test.ts +12 -0
- package/src/config.ts +28 -0
- package/src/dogwood/cli.test.ts +392 -0
- package/src/dogwood/cli.ts +545 -0
- package/src/dogwood/event-schema.test.ts +218 -0
- package/src/dogwood/event-schema.ts +318 -0
- package/src/dogwood/index.ts +198 -0
- package/src/dogwood/macros.test.ts +104 -0
- package/src/dogwood/macros.ts +229 -0
- package/src/dogwood/policy.test.ts +94 -0
- package/src/dogwood/policy.ts +141 -0
- package/src/dogwood/scan.ts +287 -0
- package/src/dogwood/serialize.test.ts +331 -0
- package/src/dogwood/serialize.ts +209 -0
- package/src/dogwood/temporal.test.ts +272 -0
- package/src/dogwood/temporal.ts +592 -0
- package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
- package/src/dogwood/testdata/default-macros.dw +23 -0
- package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
- package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
- package/src/dogwood/testdata/pinned.dwschema +31 -0
- package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
- package/src/dogwood/testdata/read-after-login.dw +17 -0
- package/src/dogwood/testdata/temporal-policies.dw +53 -0
- package/src/dogwood/upstream.ts +41 -0
- package/src/dogwood/window.ts +124 -0
- package/src/index.ts +24 -0
- package/src/lint/audit-catalog.ts +56 -0
- package/src/lint/post-synth/dogwood-helpers.ts +139 -0
- package/src/lint/post-synth/dwd-post-synth.test.ts +256 -0
- package/src/lint/post-synth/dwdc010.ts +62 -0
- package/src/lint/post-synth/dwdc011.ts +61 -0
- package/src/lint/post-synth/dwdc012.ts +46 -0
- package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
- package/src/lint/post-synth/dwde010.ts +130 -0
- package/src/lint/post-synth/dwde011.ts +108 -0
- package/src/lint/post-synth/dwds010.ts +46 -0
- package/src/lint/post-synth/index.ts +12 -0
- package/src/lint/post-synth/post-synth.test.ts +7 -3
- package/src/policy-text.ts +128 -0
- 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
|
+
}
|