@intentius/chant-lexicon-cedar 0.44.9 → 0.44.12

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 (70) hide show
  1. package/README.md +48 -2
  2. package/dist/agentcore/embed.d.ts +189 -0
  3. package/dist/agentcore/embed.d.ts.map +1 -0
  4. package/dist/agentcore/enforcement.d.ts +76 -0
  5. package/dist/agentcore/enforcement.d.ts.map +1 -0
  6. package/dist/agentcore/scan.d.ts +46 -0
  7. package/dist/agentcore/scan.d.ts.map +1 -0
  8. package/dist/avp/client.d.ts +85 -8
  9. package/dist/avp/client.d.ts.map +1 -1
  10. package/dist/codegen/docs-dogwood.d.ts +21 -0
  11. package/dist/codegen/docs-dogwood.d.ts.map +1 -0
  12. package/dist/codegen/docs.d.ts.map +1 -1
  13. package/dist/dogwood/cli.d.ts +41 -0
  14. package/dist/dogwood/cli.d.ts.map +1 -1
  15. package/dist/dogwood/index.d.ts +10 -4
  16. package/dist/dogwood/index.d.ts.map +1 -1
  17. package/dist/dogwood/replay-activity.d.ts +196 -0
  18. package/dist/dogwood/replay-activity.d.ts.map +1 -0
  19. package/dist/dogwood/replay-op.d.ts +165 -0
  20. package/dist/dogwood/replay-op.d.ts.map +1 -0
  21. package/dist/dogwood/serialize.d.ts +20 -0
  22. package/dist/dogwood/serialize.d.ts.map +1 -1
  23. package/dist/dogwood/trace.d.ts +215 -0
  24. package/dist/dogwood/trace.d.ts.map +1 -0
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/integrity.json +5 -3
  28. package/dist/lint/audit-catalog.d.ts.map +1 -1
  29. package/dist/lint/post-synth/dwdc013.d.ts +33 -0
  30. package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
  31. package/dist/lint/post-synth/index.d.ts.map +1 -1
  32. package/dist/manifest.json +1 -1
  33. package/dist/okf/index.md +1 -0
  34. package/dist/okf/rules/DWDC013.md +15 -0
  35. package/dist/okf/types/Policy.md +1 -0
  36. package/dist/op/activities/index.d.ts +18 -0
  37. package/dist/op/activities/index.d.ts.map +1 -0
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/rules/dwdc013.ts +64 -0
  40. package/dist/skills/chant-cedar-dogwood.md +327 -0
  41. package/package.json +7 -2
  42. package/src/agentcore/embed.test.ts +254 -0
  43. package/src/agentcore/embed.ts +399 -0
  44. package/src/agentcore/enforcement.test.ts +43 -0
  45. package/src/agentcore/enforcement.ts +92 -0
  46. package/src/agentcore/scan.ts +119 -0
  47. package/src/avp/OWNERSHIP.md +38 -0
  48. package/src/avp/client.test.ts +271 -0
  49. package/src/avp/client.ts +150 -16
  50. package/src/codegen/docs-dogwood.ts +1119 -0
  51. package/src/codegen/docs.ts +66 -1
  52. package/src/dogwood/cli.test.ts +122 -1
  53. package/src/dogwood/cli.ts +122 -1
  54. package/src/dogwood/index.ts +74 -1
  55. package/src/dogwood/replay-activity.test.ts +481 -0
  56. package/src/dogwood/replay-activity.ts +506 -0
  57. package/src/dogwood/replay-op.ts +242 -0
  58. package/src/dogwood/serialize.ts +37 -0
  59. package/src/dogwood/trace.test.ts +231 -0
  60. package/src/dogwood/trace.ts +471 -0
  61. package/src/index.ts +52 -0
  62. package/src/lint/audit-catalog.ts +8 -0
  63. package/src/lint/post-synth/dwd-post-synth.test.ts +119 -1
  64. package/src/lint/post-synth/dwdc013.ts +64 -0
  65. package/src/lint/post-synth/dwde-post-synth.test.ts +1 -1
  66. package/src/lint/post-synth/index.ts +2 -0
  67. package/src/op/activities/index.ts +27 -0
  68. package/src/plugin.test.ts +3 -2
  69. package/src/plugin.ts +30 -0
  70. package/src/skills/chant-cedar-dogwood.md +327 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * `dogwoodReplay` / `dogwoodReplayReport` — the replay half of PolicyReplayOp
3
+ * (#1661, epic #1646).
4
+ *
5
+ * Contributed the way the fly lexicon contributes `flyApply`: a plain exported
6
+ * async function taking one args object, re-exported from
7
+ * `src/op/activities/index.ts`, resolved **by name** by core's activity
8
+ * registry when a project lists the `cedar` lexicon. There is no Temporal
9
+ * import here and no Temporal dependency in the package — the local executor
10
+ * runs it as-is, and a Temporal worker registers the same function.
11
+ *
12
+ * What it does: takes a policy bundle (inline text or paths), an event trace
13
+ * (typed events, inline text, or a path) and a set of expectations, runs
14
+ * `dogwood replay --format json` through the existing CLI adapter in
15
+ * `./cli.ts`, and returns a typed divergence report — expected versus actual
16
+ * verdict per decision point, with the determining rules and per-evaluation
17
+ * errors carried through.
18
+ *
19
+ * Three contract details from the #1657 verification shape the code:
20
+ *
21
+ * - **Replay exits 0 even when every verdict is DENY.** A nonzero exit means
22
+ * the trace or the policy set failed to load. `./cli.ts` already refuses to
23
+ * read exit codes as verdicts; this module refuses to read a DENY as a
24
+ * failure.
25
+ * - **A run that could not happen is not a run that found nothing.** An
26
+ * unusable invocation or a fatal (a malformed trace line, an unparseable
27
+ * policy set) throws, so the step fails instead of reporting zero
28
+ * divergences.
29
+ * - **`index` is the decision-stream position, not the trace line number.** A
30
+ * history-only event contributes history and no verdict, so expectations
31
+ * written against trace lines would silently address the wrong decision.
32
+ * Expectations therefore match on `timestamp` by default, and on `index`
33
+ * only when the caller says so.
34
+ *
35
+ * The trace input is deliberately generic. An AgentCore session/decision
36
+ * history is the follow-on source (it needs the aws lexicon's activity
37
+ * surface, out of scope here) — a trace is a trace, wherever it came from.
38
+ */
39
+
40
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
41
+ import { dirname, isAbsolute, resolve } from "node:path";
42
+ import {
43
+ DOGWOOD_SEARCH_ORDER,
44
+ findDogwoodBinary,
45
+ formatDogwoodDiagnostic,
46
+ runDogwoodReplay,
47
+ type DogwoodBundle,
48
+ type DogwoodVerdict,
49
+ } from "./cli";
50
+ import { auditTrace, renderTrace, type TraceEvent, type TraceIssue } from "./trace";
51
+
52
+ /** What a replay produces on divergence, mirroring `WorkflowAuditOp`'s modes. */
53
+ export type PolicyReplayMode = "report" | "issue" | "pull-request";
54
+
55
+ /** A verdict a decision point is expected to reach. */
56
+ export type ExpectedVerdict = "allow" | "deny";
57
+
58
+ /**
59
+ * One expectation against the decision stream.
60
+ *
61
+ * Give a `timestamp` (the `@<n>` of the line) or an `index` (the 0-based
62
+ * position in the decision stream). Prefer `timestamp`: it survives a trace
63
+ * gaining a history-only event, which shifts every later index.
64
+ */
65
+ export interface ReplayExpectation {
66
+ readonly timestamp?: number;
67
+ readonly index?: number;
68
+ readonly verdict: ExpectedVerdict;
69
+ /** When set, the `.dw` rule indices the decision must be determined by. */
70
+ readonly determiningRules?: readonly number[];
71
+ /** What this decision point is proving, carried into the report. */
72
+ readonly note?: string;
73
+ }
74
+
75
+ /** One expected-versus-actual mismatch. */
76
+ export interface ReplayDivergence {
77
+ /** Decision-stream index. `-1` when the expected decision point never occurred. */
78
+ readonly index: number;
79
+ readonly timestamp: number;
80
+ /** Absent when the replay produced a decision nothing expected. */
81
+ readonly expected?: ExpectedVerdict;
82
+ /** Absent when an expected decision point produced no verdict at all. */
83
+ readonly actual?: ExpectedVerdict;
84
+ readonly determiningRules: readonly number[];
85
+ readonly errors: readonly string[];
86
+ readonly detail: string;
87
+ readonly note?: string;
88
+ }
89
+
90
+ /** What `dogwoodReplay` returns and `dogwoodReplayReport` reads back. */
91
+ export interface PolicyReplayReport {
92
+ /** True when nothing diverged and no decision point errored. */
93
+ readonly ok: boolean;
94
+ readonly mode: PolicyReplayMode;
95
+ /** Every decision point, in stream order. */
96
+ readonly verdicts: readonly DogwoodVerdict[];
97
+ readonly divergences: readonly ReplayDivergence[];
98
+ /** Divergence count — the number a search attribute or a gate reads. */
99
+ readonly findings: number;
100
+ /** Trace weaknesses found by `auditTrace`, when typed events were supplied. */
101
+ readonly traceIssues: readonly TraceIssue[];
102
+ /** Markdown, used as the report body or an issue/PR body. */
103
+ readonly summary: string;
104
+ }
105
+
106
+ /** What `dogwoodReplay` takes. Every artifact is inline text or a path. */
107
+ export interface DogwoodReplayArgs {
108
+ /** `.dw` policy set text. */
109
+ policies?: string;
110
+ /** …or a path to it. Relative paths resolve against {@link cwd}. */
111
+ policiesPath?: string;
112
+ /** Cedar action schema text (`--policy-schema`). Not optional to the CLI. */
113
+ policySchema?: string;
114
+ policySchemaPath?: string;
115
+ /** `.dwschema` event schema text (`--event-schema`). */
116
+ eventSchema?: string;
117
+ eventSchemaPath?: string;
118
+ /** `.dw` macro library text (`--macros`). */
119
+ macros?: string;
120
+ macrosPath?: string;
121
+ /** `providers.json` text (`--providers`). Rhai must be inlined under `implementation.script`. */
122
+ providers?: string;
123
+ providersPath?: string;
124
+
125
+ /** Typed events — rendered here, and audited for the both-bags trap. */
126
+ traceEvents?: readonly TraceEvent[];
127
+ /** …or the trace text as it would appear in a `.log`. */
128
+ trace?: string;
129
+ /** …or a path to it. */
130
+ tracePath?: string;
131
+ /**
132
+ * Event kinds that decide, for the trace audit. Default `["request"]` — the
133
+ * truth is whichever kinds the `.dwschema` marks `decision`.
134
+ */
135
+ traceDecisionKinds?: readonly string[];
136
+
137
+ /** What each decision point must decide. An empty list replays and reports. */
138
+ expect?: readonly ReplayExpectation[];
139
+ /** Default `report`. */
140
+ mode?: PolicyReplayMode;
141
+
142
+ /** Explicit binary path. Otherwise resolved by {@link findDogwoodBinary}. */
143
+ binary?: string;
144
+ /** Base directory for every relative path. Default `process.cwd()`. */
145
+ cwd?: string;
146
+ /** When set, the report is written here as JSON for the Report phase to read. */
147
+ reportPath?: string;
148
+ }
149
+
150
+ async function textOf(
151
+ inline: string | undefined,
152
+ path: string | undefined,
153
+ cwd: string,
154
+ what: string,
155
+ ): Promise<string | undefined> {
156
+ if (inline !== undefined) return inline;
157
+ if (path === undefined) return undefined;
158
+ const full = isAbsolute(path) ? path : resolve(cwd, path);
159
+ try {
160
+ return await readFile(full, "utf-8");
161
+ } catch (err) {
162
+ throw new Error(`dogwood replay: could not read ${what} at ${full} — ${err instanceof Error ? err.message : String(err)}`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Assemble the bundle and the trace from whichever form the caller supplied.
168
+ *
169
+ * Exported because the composite's Artifacts phase writes files and the Replay
170
+ * phase names them: resolving the same way in a test as in the Op is the point
171
+ * of having one function do it.
172
+ */
173
+ export async function resolveReplayInputs(
174
+ args: DogwoodReplayArgs,
175
+ ): Promise<{ bundle: DogwoodBundle; trace: string; traceIssues: TraceIssue[] }> {
176
+ const cwd = args.cwd ?? process.cwd();
177
+
178
+ const policies = await textOf(args.policies, args.policiesPath, cwd, "the .dw policy set");
179
+ if (policies === undefined) {
180
+ throw new Error("dogwood replay: no policy set — pass `policies` or `policiesPath`");
181
+ }
182
+
183
+ const policySchema = await textOf(args.policySchema, args.policySchemaPath, cwd, "the Cedar action schema");
184
+ if (policySchema === undefined) {
185
+ throw new Error(
186
+ "dogwood replay: no action schema — `--policy-schema` is not optional to the CLI, so pass `policySchema` or `policySchemaPath`",
187
+ );
188
+ }
189
+
190
+ const eventSchema = await textOf(args.eventSchema, args.eventSchemaPath, cwd, "the .dwschema event schema");
191
+ const macros = await textOf(args.macros, args.macrosPath, cwd, "the macro library");
192
+ const providers = await textOf(args.providers, args.providersPath, cwd, "providers.json");
193
+
194
+ let trace: string | undefined;
195
+ let traceIssues: TraceIssue[] = [];
196
+ if (args.traceEvents && args.traceEvents.length > 0) {
197
+ // Only the typed form can be audited — there is no trace parser on this
198
+ // side, and guessing one from a regex would report confident nonsense
199
+ // about a format a sync can retune.
200
+ traceIssues = auditTrace(args.traceEvents, {
201
+ ...(args.traceDecisionKinds ? { decisionKinds: args.traceDecisionKinds } : {}),
202
+ });
203
+ trace = renderTrace(args.traceEvents);
204
+ } else {
205
+ trace = await textOf(args.trace, args.tracePath, cwd, "the event trace");
206
+ }
207
+ if (trace === undefined) {
208
+ throw new Error("dogwood replay: no trace — pass `traceEvents`, `trace` or `tracePath`");
209
+ }
210
+
211
+ return {
212
+ bundle: {
213
+ policies,
214
+ policySchema,
215
+ ...(eventSchema !== undefined ? { eventSchema } : {}),
216
+ ...(macros !== undefined ? { macros } : {}),
217
+ ...(providers !== undefined ? { providers } : {}),
218
+ },
219
+ trace,
220
+ traceIssues,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Match expectations against the decision stream.
226
+ *
227
+ * Timestamp matching consumes verdicts left to right, so two expectations at
228
+ * the same `@n` address the first and second decision there rather than both
229
+ * addressing the first. A verdict nothing expected is reported too — a policy
230
+ * set that starts deciding somewhere new is drift, and the usual reason a
231
+ * replay is being run at all.
232
+ */
233
+ export function compareVerdicts(
234
+ verdicts: readonly DogwoodVerdict[],
235
+ expectations: readonly ReplayExpectation[],
236
+ ): ReplayDivergence[] {
237
+ const divergences: ReplayDivergence[] = [];
238
+ const claimed = new Set<number>();
239
+
240
+ for (const expectation of expectations) {
241
+ if (expectation.index === undefined && expectation.timestamp === undefined) {
242
+ throw new Error("dogwood replay: an expectation needs a `timestamp` or an `index`");
243
+ }
244
+
245
+ const position =
246
+ expectation.index !== undefined
247
+ ? verdicts.findIndex((v) => v.index === expectation.index)
248
+ : verdicts.findIndex((v, i) => !claimed.has(i) && v.timestamp === expectation.timestamp);
249
+
250
+ if (position === -1) {
251
+ const where =
252
+ expectation.index !== undefined ? `decision index ${expectation.index}` : `@${String(expectation.timestamp)}`;
253
+ divergences.push({
254
+ index: -1,
255
+ timestamp: expectation.timestamp ?? -1,
256
+ expected: expectation.verdict,
257
+ determiningRules: [],
258
+ errors: [],
259
+ detail: `no decision point at ${where} — the trace produced ${verdicts.length} decision(s), and a history-only event produces none`,
260
+ ...(expectation.note ? { note: expectation.note } : {}),
261
+ });
262
+ continue;
263
+ }
264
+
265
+ claimed.add(position);
266
+ const actual = verdicts[position];
267
+
268
+ if (actual.verdict !== expectation.verdict) {
269
+ divergences.push({
270
+ index: actual.index,
271
+ timestamp: actual.timestamp,
272
+ expected: expectation.verdict,
273
+ actual: actual.verdict,
274
+ determiningRules: actual.determiningRules,
275
+ errors: actual.errors,
276
+ detail: `expected ${expectation.verdict.toUpperCase()}, replayed ${actual.verdict.toUpperCase()}${
277
+ actual.determiningRules.length > 0 ? ` (rules: ${actual.determiningRules.join(", ")})` : ""
278
+ }`,
279
+ ...(expectation.note ? { note: expectation.note } : {}),
280
+ });
281
+ continue;
282
+ }
283
+
284
+ if (expectation.determiningRules !== undefined) {
285
+ const want = [...expectation.determiningRules].sort((a, b) => a - b).join(", ");
286
+ const got = [...actual.determiningRules].sort((a, b) => a - b).join(", ");
287
+ if (want !== got) {
288
+ divergences.push({
289
+ index: actual.index,
290
+ timestamp: actual.timestamp,
291
+ expected: expectation.verdict,
292
+ actual: actual.verdict,
293
+ determiningRules: actual.determiningRules,
294
+ errors: actual.errors,
295
+ detail: `${actual.verdict.toUpperCase()} as expected, but determined by rules [${got}] rather than [${want}] — the decision is right for a different reason`,
296
+ ...(expectation.note ? { note: expectation.note } : {}),
297
+ });
298
+ continue;
299
+ }
300
+ }
301
+
302
+ if (actual.errors.length > 0) {
303
+ divergences.push({
304
+ index: actual.index,
305
+ timestamp: actual.timestamp,
306
+ expected: expectation.verdict,
307
+ actual: actual.verdict,
308
+ determiningRules: actual.determiningRules,
309
+ errors: actual.errors,
310
+ detail: `${actual.verdict.toUpperCase()} as expected, but the evaluation errored: ${actual.errors.join("; ")}`,
311
+ ...(expectation.note ? { note: expectation.note } : {}),
312
+ });
313
+ }
314
+ }
315
+
316
+ verdicts.forEach((verdict, position) => {
317
+ if (claimed.has(position)) return;
318
+ if (expectations.length === 0) return;
319
+ divergences.push({
320
+ index: verdict.index,
321
+ timestamp: verdict.timestamp,
322
+ actual: verdict.verdict,
323
+ determiningRules: verdict.determiningRules,
324
+ errors: verdict.errors,
325
+ detail: `an unexpected decision point: @${verdict.timestamp} replayed ${verdict.verdict.toUpperCase()} and nothing expected a decision there`,
326
+ });
327
+ });
328
+
329
+ // Errors on decision points nobody wrote an expectation for still matter —
330
+ // a provider with no inlined Rhai script errors on every evaluation, and a
331
+ // replay with no expectations at all would otherwise report itself clean.
332
+ if (expectations.length === 0) {
333
+ for (const verdict of verdicts) {
334
+ if (verdict.errors.length === 0) continue;
335
+ divergences.push({
336
+ index: verdict.index,
337
+ timestamp: verdict.timestamp,
338
+ actual: verdict.verdict,
339
+ determiningRules: verdict.determiningRules,
340
+ errors: verdict.errors,
341
+ detail: `@${verdict.timestamp} evaluated with errors: ${verdict.errors.join("; ")}`,
342
+ });
343
+ }
344
+ }
345
+
346
+ return divergences.sort((a, b) => a.timestamp - b.timestamp || a.index - b.index);
347
+ }
348
+
349
+ /** The markdown body — printed in `report` mode, posted in the other two. */
350
+ export function renderReplaySummary(report: Omit<PolicyReplayReport, "summary">): string {
351
+ const lines = ["## Policy replay", ""];
352
+ lines.push(
353
+ `${report.verdicts.length} decision point(s) replayed; ${report.divergences.length} divergence(s).`,
354
+ "",
355
+ );
356
+
357
+ if (report.divergences.length > 0) {
358
+ lines.push("| @ | index | expected | replayed | detail |", "|---|---|---|---|---|");
359
+ for (const d of report.divergences) {
360
+ lines.push(
361
+ `| ${d.timestamp === -1 ? "—" : `@${d.timestamp}`} | ${d.index === -1 ? "—" : d.index} | ${
362
+ d.expected ?? "—"
363
+ } | ${d.actual ?? "—"} | ${d.detail}${d.note ? ` — ${d.note}` : ""} |`,
364
+ );
365
+ }
366
+ lines.push("");
367
+ }
368
+
369
+ if (report.traceIssues.length > 0) {
370
+ lines.push(
371
+ "### Trace weaknesses",
372
+ "",
373
+ "These do not fail a replay — they make one report a verdict it did not really test.",
374
+ "",
375
+ );
376
+ for (const issue of report.traceIssues) {
377
+ lines.push(`- \`${issue.kind}\` @${issue.timestamp}: ${issue.message}`);
378
+ }
379
+ lines.push("");
380
+ }
381
+
382
+ if (report.divergences.length === 0 && report.traceIssues.length === 0) {
383
+ lines.push("Every decision point replayed to the verdict it was expected to reach.", "");
384
+ }
385
+
386
+ return lines.join("\n");
387
+ }
388
+
389
+ /**
390
+ * Replay a policy bundle against a trace and report divergence.
391
+ *
392
+ * Throws when the CLI could not be used or the run was fatal. That is the
393
+ * distinction `./cli.ts` draws and this preserves: a replay that did not
394
+ * happen must fail the step, never report zero divergences.
395
+ */
396
+ export async function dogwoodReplay(args: DogwoodReplayArgs): Promise<PolicyReplayReport> {
397
+ const cwd = args.cwd ?? process.cwd();
398
+ const mode = args.mode ?? "report";
399
+
400
+ const binary = args.binary ?? findDogwoodBinary(cwd)?.path;
401
+ if (!binary) {
402
+ throw new Error(
403
+ `dogwood replay: no \`dogwood\` binary. chant looked at ${DOGWOOD_SEARCH_ORDER}. Upstream ships only a Rust CLI — build it from dogwood-policy/dogwood and point chant at it.`,
404
+ );
405
+ }
406
+
407
+ const { bundle, trace, traceIssues } = await resolveReplayInputs(args);
408
+ const result = runDogwoodReplay(binary, bundle, trace);
409
+
410
+ if (result.kind === "unusable") {
411
+ throw new Error(`dogwood replay: ${result.reason}`);
412
+ }
413
+ if (result.kind === "fatal") {
414
+ const related = result.related.map((d) => `\n ${formatDogwoodDiagnostic(d)}`).join("");
415
+ throw new Error(`dogwood replay: ${formatDogwoodDiagnostic(result.error)}${related}`);
416
+ }
417
+
418
+ const divergences = compareVerdicts(result.verdicts, args.expect ?? []);
419
+ const partial = {
420
+ ok: divergences.length === 0,
421
+ mode,
422
+ verdicts: result.verdicts,
423
+ divergences,
424
+ findings: divergences.length,
425
+ traceIssues,
426
+ };
427
+ const report: PolicyReplayReport = { ...partial, summary: renderReplaySummary(partial) };
428
+
429
+ if (args.reportPath) {
430
+ const full = isAbsolute(args.reportPath) ? args.reportPath : resolve(cwd, args.reportPath);
431
+ await mkdir(dirname(full), { recursive: true });
432
+ await writeFile(full, JSON.stringify(report, null, 2) + "\n", "utf-8");
433
+ }
434
+
435
+ return report;
436
+ }
437
+
438
+ /** What `dogwoodReplayReport` takes. */
439
+ export interface DogwoodReplayReportArgs {
440
+ /** The JSON `dogwoodReplay` wrote. Relative paths resolve against {@link cwd}. */
441
+ reportPath?: string;
442
+ /** …or the report itself, for a caller holding it already. */
443
+ report?: PolicyReplayReport;
444
+ /** Overrides the mode recorded in the report. */
445
+ mode?: PolicyReplayMode;
446
+ /** Title for the issue or pull request. */
447
+ title?: string;
448
+ cwd?: string;
449
+ /** Fail the step when the replay diverged. Default false — the mode decides. */
450
+ failOnDivergence?: boolean;
451
+ }
452
+
453
+ /** What the Report phase produces. */
454
+ export interface PolicyReplayDispatch {
455
+ readonly mode: PolicyReplayMode;
456
+ readonly findings: number;
457
+ readonly title: string;
458
+ /** The markdown to print, or to use as an issue/PR body. */
459
+ readonly body: string;
460
+ /** True when there is something to say — an issue/PR is only worth opening then. */
461
+ readonly actionable: boolean;
462
+ }
463
+
464
+ /**
465
+ * Turn a written replay report into the thing the finding mode calls for.
466
+ *
467
+ * It renders and returns; it does not open anything. Same as
468
+ * `workflowSupplyChainAudit`, and for the same reason — the cedar lexicon has
469
+ * no forge client and should not grow one to reach GitHub, GitLab or Forgejo.
470
+ * `report` mode prints the body; `issue` and `pull-request` hand back the
471
+ * title and body for whatever step opens them.
472
+ */
473
+ export async function dogwoodReplayReport(args: DogwoodReplayReportArgs): Promise<PolicyReplayDispatch> {
474
+ const cwd = args.cwd ?? process.cwd();
475
+
476
+ let report = args.report;
477
+ if (!report) {
478
+ if (!args.reportPath) {
479
+ throw new Error("dogwood replay report: pass `report` or `reportPath`");
480
+ }
481
+ const full = isAbsolute(args.reportPath) ? args.reportPath : resolve(cwd, args.reportPath);
482
+ try {
483
+ report = JSON.parse(await readFile(full, "utf-8")) as PolicyReplayReport;
484
+ } catch (err) {
485
+ throw new Error(
486
+ `dogwood replay report: could not read the replay report at ${full} — ${err instanceof Error ? err.message : String(err)}. The Replay phase writes it; a Report phase that runs without one has nothing to report on.`,
487
+ );
488
+ }
489
+ }
490
+
491
+ const mode = args.mode ?? report.mode;
492
+ const findings = report.findings;
493
+ const actionable = findings > 0 || report.traceIssues.length > 0;
494
+ const title = args.title ?? `Policy replay: ${findings} divergence(s)`;
495
+
496
+ if (mode === "report") {
497
+ // eslint-disable-next-line no-console -- report mode's whole output is this.
498
+ console.log(report.summary);
499
+ }
500
+
501
+ if (args.failOnDivergence && findings > 0) {
502
+ throw new Error(`dogwood replay: ${findings} divergence(s) between the declared policy set and the replayed trace`);
503
+ }
504
+
505
+ return { mode, findings, title, body: report.summary, actionable };
506
+ }