@intentius/chant-lexicon-cedar 0.44.8 → 0.44.10

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 (149) hide show
  1. package/README.md +106 -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/codegen/docs-dogwood.d.ts +21 -0
  9. package/dist/codegen/docs-dogwood.d.ts.map +1 -0
  10. package/dist/codegen/docs.d.ts.map +1 -1
  11. package/dist/codegen/package.d.ts.map +1 -1
  12. package/dist/config.d.ts +25 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/dogwood/cli.d.ts +247 -0
  15. package/dist/dogwood/cli.d.ts.map +1 -0
  16. package/dist/dogwood/event-schema.d.ts +161 -0
  17. package/dist/dogwood/event-schema.d.ts.map +1 -0
  18. package/dist/dogwood/index.d.ts +39 -0
  19. package/dist/dogwood/index.d.ts.map +1 -0
  20. package/dist/dogwood/macros.d.ts +96 -0
  21. package/dist/dogwood/macros.d.ts.map +1 -0
  22. package/dist/dogwood/policy.d.ts +120 -0
  23. package/dist/dogwood/policy.d.ts.map +1 -0
  24. package/dist/dogwood/replay-activity.d.ts +196 -0
  25. package/dist/dogwood/replay-activity.d.ts.map +1 -0
  26. package/dist/dogwood/replay-op.d.ts +165 -0
  27. package/dist/dogwood/replay-op.d.ts.map +1 -0
  28. package/dist/dogwood/scan.d.ts +109 -0
  29. package/dist/dogwood/scan.d.ts.map +1 -0
  30. package/dist/dogwood/serialize.d.ts +66 -0
  31. package/dist/dogwood/serialize.d.ts.map +1 -0
  32. package/dist/dogwood/temporal.d.ts +259 -0
  33. package/dist/dogwood/temporal.d.ts.map +1 -0
  34. package/dist/dogwood/trace.d.ts +215 -0
  35. package/dist/dogwood/trace.d.ts.map +1 -0
  36. package/dist/dogwood/upstream.d.ts +41 -0
  37. package/dist/dogwood/upstream.d.ts.map +1 -0
  38. package/dist/dogwood/window.d.ts +73 -0
  39. package/dist/dogwood/window.d.ts.map +1 -0
  40. package/dist/index.d.ts +12 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/integrity.json +15 -3
  43. package/dist/lint/audit-catalog.d.ts.map +1 -1
  44. package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
  45. package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
  46. package/dist/lint/post-synth/dwdc010.d.ts +25 -0
  47. package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
  48. package/dist/lint/post-synth/dwdc011.d.ts +19 -0
  49. package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
  50. package/dist/lint/post-synth/dwdc012.d.ts +21 -0
  51. package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
  52. package/dist/lint/post-synth/dwdc013.d.ts +33 -0
  53. package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
  54. package/dist/lint/post-synth/dwde010.d.ts +32 -0
  55. package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
  56. package/dist/lint/post-synth/dwde011.d.ts +33 -0
  57. package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
  58. package/dist/lint/post-synth/dwds010.d.ts +24 -0
  59. package/dist/lint/post-synth/dwds010.d.ts.map +1 -0
  60. package/dist/lint/post-synth/index.d.ts.map +1 -1
  61. package/dist/manifest.json +1 -1
  62. package/dist/okf/index.md +7 -0
  63. package/dist/okf/rules/DWDC010.md +11 -0
  64. package/dist/okf/rules/DWDC011.md +11 -0
  65. package/dist/okf/rules/DWDC012.md +11 -0
  66. package/dist/okf/rules/DWDC013.md +15 -0
  67. package/dist/okf/rules/DWDE010.md +11 -0
  68. package/dist/okf/rules/DWDE011.md +11 -0
  69. package/dist/okf/rules/DWDS010.md +11 -0
  70. package/dist/okf/types/Policy.md +1 -0
  71. package/dist/op/activities/index.d.ts +18 -0
  72. package/dist/op/activities/index.d.ts.map +1 -0
  73. package/dist/plugin.d.ts.map +1 -1
  74. package/dist/policy-text.d.ts +53 -0
  75. package/dist/policy-text.d.ts.map +1 -0
  76. package/dist/rules/dogwood-helpers.ts +139 -0
  77. package/dist/rules/dwdc010.ts +62 -0
  78. package/dist/rules/dwdc011.ts +61 -0
  79. package/dist/rules/dwdc012.ts +46 -0
  80. package/dist/rules/dwdc013.ts +64 -0
  81. package/dist/rules/dwde010.ts +130 -0
  82. package/dist/rules/dwde011.ts +108 -0
  83. package/dist/rules/dwds010.ts +46 -0
  84. package/dist/serializer.d.ts +10 -18
  85. package/dist/serializer.d.ts.map +1 -1
  86. package/dist/skills/chant-cedar-authoring.md +180 -0
  87. package/dist/skills/chant-cedar-avp-embedding.md +125 -0
  88. package/dist/skills/chant-cedar-dogwood.md +327 -0
  89. package/dist/skills/chant-cedar-meta-policy.md +119 -0
  90. package/package.json +7 -2
  91. package/src/agentcore/embed.test.ts +254 -0
  92. package/src/agentcore/embed.ts +399 -0
  93. package/src/agentcore/enforcement.test.ts +43 -0
  94. package/src/agentcore/enforcement.ts +92 -0
  95. package/src/agentcore/scan.ts +119 -0
  96. package/src/codegen/docs-dogwood.ts +1119 -0
  97. package/src/codegen/docs.ts +66 -1
  98. package/src/codegen/package.ts +3 -2
  99. package/src/config.test.ts +12 -0
  100. package/src/config.ts +28 -0
  101. package/src/dogwood/cli.test.ts +513 -0
  102. package/src/dogwood/cli.ts +666 -0
  103. package/src/dogwood/event-schema.test.ts +218 -0
  104. package/src/dogwood/event-schema.ts +318 -0
  105. package/src/dogwood/index.ts +271 -0
  106. package/src/dogwood/macros.test.ts +104 -0
  107. package/src/dogwood/macros.ts +229 -0
  108. package/src/dogwood/policy.test.ts +94 -0
  109. package/src/dogwood/policy.ts +141 -0
  110. package/src/dogwood/replay-activity.test.ts +481 -0
  111. package/src/dogwood/replay-activity.ts +506 -0
  112. package/src/dogwood/replay-op.ts +242 -0
  113. package/src/dogwood/scan.ts +287 -0
  114. package/src/dogwood/serialize.test.ts +331 -0
  115. package/src/dogwood/serialize.ts +246 -0
  116. package/src/dogwood/temporal.test.ts +272 -0
  117. package/src/dogwood/temporal.ts +592 -0
  118. package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
  119. package/src/dogwood/testdata/default-macros.dw +23 -0
  120. package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
  121. package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
  122. package/src/dogwood/testdata/pinned.dwschema +31 -0
  123. package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
  124. package/src/dogwood/testdata/read-after-login.dw +17 -0
  125. package/src/dogwood/testdata/temporal-policies.dw +53 -0
  126. package/src/dogwood/trace.test.ts +231 -0
  127. package/src/dogwood/trace.ts +471 -0
  128. package/src/dogwood/upstream.ts +41 -0
  129. package/src/dogwood/window.ts +124 -0
  130. package/src/index.ts +76 -0
  131. package/src/lint/audit-catalog.ts +64 -0
  132. package/src/lint/post-synth/dogwood-helpers.ts +139 -0
  133. package/src/lint/post-synth/dwd-post-synth.test.ts +374 -0
  134. package/src/lint/post-synth/dwdc010.ts +62 -0
  135. package/src/lint/post-synth/dwdc011.ts +61 -0
  136. package/src/lint/post-synth/dwdc012.ts +46 -0
  137. package/src/lint/post-synth/dwdc013.ts +64 -0
  138. package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
  139. package/src/lint/post-synth/dwde010.ts +130 -0
  140. package/src/lint/post-synth/dwde011.ts +108 -0
  141. package/src/lint/post-synth/dwds010.ts +46 -0
  142. package/src/lint/post-synth/index.ts +14 -0
  143. package/src/lint/post-synth/post-synth.test.ts +7 -3
  144. package/src/op/activities/index.ts +27 -0
  145. package/src/plugin.test.ts +3 -2
  146. package/src/plugin.ts +30 -0
  147. package/src/policy-text.ts +128 -0
  148. package/src/serializer.ts +71 -109
  149. package/src/skills/chant-cedar-dogwood.md +327 -0
@@ -21,6 +21,13 @@
21
21
  import { dirname, join } from "path";
22
22
  import { fileURLToPath } from "url";
23
23
  import { docsPipeline, writeDocsSite, type DocsConfig } from "@intentius/chant/codegen/docs";
24
+ import {
25
+ dogwoodEventSchemas,
26
+ dogwoodOverview,
27
+ dogwoodReplay,
28
+ dogwoodTemporalPolicies,
29
+ dogwoodValidation,
30
+ } from "./docs-dogwood";
24
31
 
25
32
  const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
26
33
 
@@ -67,7 +74,14 @@ any evaluator with chant nowhere in sight.
67
74
  There is no \`cedarGate()\` and there will not be one.
68
75
  [Organizational policy](/chant/guide/organizational-policy/) in chant is
69
76
  TypeScript post-synth checks; a second policy engine would duplicate the lint
70
- engine. chant compiles *to* Cedar; it is not governed *by* Cedar.`;
77
+ engine. chant compiles *to* Cedar; it is not governed *by* Cedar.
78
+
79
+ ## The dogwood dialect
80
+
81
+ Cedar with temporal operators, shipping inside this lexicon as a pre-release
82
+ surface under the \`DWD\` id family: a policy that can depend on what already
83
+ happened in a session. Start at [The Dogwood Dialect](./dogwood), which is
84
+ honest about upstream's governance before it shows you a builder.`;
71
85
 
72
86
  // ── getting-started ───────────────────────────────────────────────
73
87
 
@@ -858,6 +872,57 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise<voi
858
872
  description: "The AVP statement seam, and the other Cedar evaluators.",
859
873
  content: avpPage,
860
874
  },
875
+ // The dogwood dialect (#1662). `sidebar: false` on each, because these
876
+ // five belong under one group rather than flat after the cedar pages —
877
+ // the group is declared in `sidebarExtra` below, and `buildSidebar`
878
+ // would otherwise list them twice.
879
+ {
880
+ slug: "dogwood",
881
+ title: "The Dogwood Dialect",
882
+ description: "Cedar with temporal operators — what ships, and what pre-release means here.",
883
+ content: dogwoodOverview,
884
+ sidebar: false,
885
+ },
886
+ {
887
+ slug: "dogwood-temporal-policies",
888
+ title: "Temporal Policies",
889
+ description: "The typed builders, the parser primitives, and which operators are really macros.",
890
+ content: dogwoodTemporalPolicies,
891
+ sidebar: false,
892
+ },
893
+ {
894
+ slug: "dogwood-event-schemas",
895
+ title: "Event Schemas",
896
+ description: "The .dwschema surface, the callerPrincipal pin, and what opting out of it widens.",
897
+ content: dogwoodEventSchemas,
898
+ sidebar: false,
899
+ },
900
+ {
901
+ slug: "dogwood-validation",
902
+ title: "Dogwood Validation",
903
+ description: "The DWD walls that always run, and the CLI-gated checks that need the binary.",
904
+ content: dogwoodValidation,
905
+ sidebar: false,
906
+ },
907
+ {
908
+ slug: "dogwood-replay",
909
+ title: "Replay",
910
+ description: "Typed traces, PolicyReplayOp, and the both-bags trap that makes half a trace pass.",
911
+ content: dogwoodReplay,
912
+ sidebar: false,
913
+ },
914
+ ],
915
+ sidebarExtra: [
916
+ {
917
+ label: "Dogwood",
918
+ items: [
919
+ { label: "The Dialect", slug: "dogwood" },
920
+ { label: "Temporal Policies", slug: "dogwood-temporal-policies" },
921
+ { label: "Event Schemas", slug: "dogwood-event-schemas" },
922
+ { label: "Validation", slug: "dogwood-validation" },
923
+ { label: "Replay", slug: "dogwood-replay" },
924
+ ],
925
+ },
861
926
  ],
862
927
  };
863
928
 
@@ -2,10 +2,12 @@
2
2
  * Cedar lexicon packaging — delegates to core's packagePipeline.
3
3
  */
4
4
 
5
+ import { cedarPlugin } from "../plugin";
5
6
  import { readFileSync } from "fs";
6
7
  import { dirname, join } from "path";
7
8
  import { fileURLToPath } from "url";
8
9
  import {
10
+ collectSkills,
9
11
  packagePipeline,
10
12
  type PackageOptions,
11
13
  type PackageResult,
@@ -42,8 +44,7 @@ export async function packageLexicon(opts: PackageOptions = {}): Promise<Package
42
44
 
43
45
  srcDir: pkgDir,
44
46
 
45
- // Skills, initTemplates and the docs site are #1654.
46
- collectSkills: () => new Map(),
47
+ collectSkills: () => collectSkills(cedarPlugin.skills?.() ?? []),
47
48
 
48
49
  version: pkgJson.version ?? "0.0.0",
49
50
  },
@@ -37,6 +37,18 @@ describe("cedar config namespace", () => {
37
37
  it("leaves everything optional, so an empty namespace is legal", () => {
38
38
  expect(cedarConfigSchema.parse({})).toEqual({});
39
39
  });
40
+
41
+ it("takes the dogwood binary path (#1659)", () => {
42
+ const parsed = cedarConfigSchema.parse({ dogwood: { binary: "../dogwood/target/release/dogwood" } });
43
+ expect(parsed.dogwood?.binary).toBe("../dogwood/target/release/dogwood");
44
+ });
45
+
46
+ it("rejects a typo in the dogwood namespace, where a silent skip would follow", () => {
47
+ // A misspelled key here means "no binary found", and "no binary found" is
48
+ // an advisory rather than a failure — exactly the case strictObject exists
49
+ // to catch before it turns into a validation gap nobody notices.
50
+ expect(() => cedarConfigSchema.parse({ dogwood: { path: "/usr/local/bin/dogwood" } })).toThrow();
51
+ });
40
52
  });
41
53
 
42
54
  describe("schema resolution", () => {
package/src/config.ts CHANGED
@@ -71,6 +71,34 @@ export const cedarConfigSchema = z.strictObject({
71
71
  requireProjectSchema: z.boolean().optional(),
72
72
  })
73
73
  .optional(),
74
+
75
+ /**
76
+ * The dogwood dialect's CLI-gated leg (#1659).
77
+ *
78
+ * `strictObject` again, for the reason the outer one is: a typo here is the
79
+ * difference between "full .dw validation ran" and "DWDE010 said it could
80
+ * not run", and the second is easy to skim past.
81
+ */
82
+ dogwood: z
83
+ .strictObject({
84
+ /**
85
+ * Path to the `dogwood` binary, absolute or relative to the config file.
86
+ *
87
+ * Optional, and its absence is not an error: DWDE010 warns-and-skips
88
+ * with an issue-linked advisory when no binary is found, which is the
89
+ * epic's explicit exception. Set this when the binary is built somewhere
90
+ * `PATH` does not reach — the on-demand harness shape the epic borrows
91
+ * from forgejo-runtime-e2e.
92
+ *
93
+ * A `chant.config.ts` cannot be read from inside a post-synth check —
94
+ * `check()` is synchronous and evaluating project code is not — so this
95
+ * key is honoured from `chant.config.json` and the `CHANT_DOGWOOD_BINARY`
96
+ * environment variable covers the TypeScript-config case. See
97
+ * `src/dogwood/cli.ts`.
98
+ */
99
+ binary: z.string().optional(),
100
+ })
101
+ .optional(),
74
102
  });
75
103
 
76
104
  export type CedarConfig = z.infer<typeof cedarConfigSchema>;
@@ -0,0 +1,513 @@
1
+ /**
2
+ * The `dogwood` CLI adapter (#1659).
3
+ *
4
+ * Every test here injects a runner. Nothing in the gating suite executes a
5
+ * real binary — the epic's own rule ("nothing in gating CI executes the
6
+ * dogwood binary"), and the reason the adapter takes a runner at all. The
7
+ * JSON fixtures are upstream's two report shapes, read from the pinned
8
+ * `dogwood-cli/src/ops.rs` and `dogwood-cli/src/error.rs` structs rather than
9
+ * from the human text, which is the surface most likely to be retuned by a
10
+ * sync.
11
+ */
12
+ import { describe, test, expect, afterEach } from "vitest";
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import {
18
+ DOGWOOD_BINARY_ENV,
19
+ configureDogwoodCli,
20
+ findDogwoodBinary,
21
+ formatDogwoodDiagnostic,
22
+ parseLowerOutput,
23
+ parseReplayOutput,
24
+ parseValidateOutput,
25
+ resetDogwoodCli,
26
+ runDogwoodLower,
27
+ runDogwoodReplay,
28
+ runDogwoodValidate,
29
+ type DogwoodRun,
30
+ type DogwoodRunner,
31
+ } from "./cli";
32
+
33
+ const TESTDATA = join(fileURLToPath(new URL(".", import.meta.url)), "testdata");
34
+
35
+ /**
36
+ * A real `dogwood lower --format json` artifact set, adapted from
37
+ * dogwood-policy/dogwood@5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c (Apache-2.0),
38
+ * `dogwood-docs/examples/read_after_login`.
39
+ */
40
+ const LOWERED_READ_AFTER_LOGIN: unknown = JSON.parse(
41
+ readFileSync(join(TESTDATA, "lowered-read-after-login.json"), "utf-8"),
42
+ );
43
+
44
+ afterEach(() => {
45
+ resetDogwoodCli();
46
+ delete process.env[DOGWOOD_BINARY_ENV];
47
+ });
48
+
49
+ function run(over: Partial<DogwoodRun>): DogwoodRun {
50
+ return { status: 0, stdout: "", stderr: "", ...over };
51
+ }
52
+
53
+ function json(value: unknown, status = 0): DogwoodRun {
54
+ return run({ status, stdout: JSON.stringify(value) });
55
+ }
56
+
57
+ // ── The validate report shape ──────────────────────────────────────
58
+
59
+ describe("parseValidateOutput — the ValidateReport shape", () => {
60
+ test("a clean report is a pass", () => {
61
+ const result = parseValidateOutput(
62
+ json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] }),
63
+ );
64
+ expect(result).toEqual({ kind: "passed", warnings: [] });
65
+ });
66
+
67
+ test("warnings do not fail the report, and are carried through", () => {
68
+ const result = parseValidateOutput(
69
+ json({
70
+ passed: true,
71
+ passed_without_warnings: false,
72
+ errors: [],
73
+ warnings: [{ severity: "warning", message: "policy is impossible", spanned: false }],
74
+ }),
75
+ );
76
+ expect(result.kind).toBe("passed");
77
+ if (result.kind !== "passed") return;
78
+ expect(result.warnings).toHaveLength(1);
79
+ expect(result.warnings[0].message).toBe("policy is impossible");
80
+ });
81
+
82
+ test("a rejection carries every finding, with its code, help and byte spans", () => {
83
+ const result = parseValidateOutput(
84
+ json(
85
+ {
86
+ passed: false,
87
+ passed_without_warnings: false,
88
+ errors: [
89
+ {
90
+ severity: "error",
91
+ code: "extension",
92
+ message:
93
+ 'predicate `Drupe::Action::"NoSuchAction"::request` does not name a declared event',
94
+ labels: [{ start: 120, len: 42, message: "here" }],
95
+ spanned: true,
96
+ },
97
+ {
98
+ severity: "error",
99
+ code: "cedar",
100
+ message: "attribute `input.nosuchfield` in context for Drupe::Action::\"Read\" not found",
101
+ labels: [],
102
+ help: "did you mean `user`?",
103
+ spanned: false,
104
+ },
105
+ ],
106
+ warnings: [],
107
+ },
108
+ 2,
109
+ ),
110
+ );
111
+ expect(result.kind).toBe("rejected");
112
+ if (result.kind !== "rejected") return;
113
+ expect(result.errors).toHaveLength(2);
114
+ expect(result.errors[0].code).toBe("extension");
115
+ expect(result.errors[0].labels).toEqual([{ start: 120, len: 42, message: "here" }]);
116
+ expect(result.errors[1].help).toBe("did you mean `user`?");
117
+ });
118
+
119
+ test("the report wins over the exit code — passed:false at exit 0 is still a rejection", () => {
120
+ const result = parseValidateOutput(
121
+ json({
122
+ passed: false,
123
+ passed_without_warnings: false,
124
+ errors: [{ severity: "error", message: "nope", spanned: false }],
125
+ warnings: [],
126
+ }),
127
+ );
128
+ expect(result.kind).toBe("rejected");
129
+ });
130
+
131
+ test("and in the other direction — a clean report at a non-zero exit is still a pass", () => {
132
+ const result = parseValidateOutput(
133
+ json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] }, 2),
134
+ );
135
+ expect(result.kind).toBe("passed");
136
+ });
137
+ });
138
+
139
+ // ── The fatal OpError shape ────────────────────────────────────────
140
+
141
+ describe("parseValidateOutput — the OpError shape", () => {
142
+ test("a fatal parse error is its own arm, not an entry in errors[]", () => {
143
+ const result = parseValidateOutput(
144
+ json(
145
+ {
146
+ severity: "error",
147
+ message: "expected `within` after `formerly`",
148
+ labels: [{ start: 64, len: 8 }],
149
+ spanned: true,
150
+ related: [{ severity: "error", message: "and a second parse error", spanned: false }],
151
+ },
152
+ 2,
153
+ ),
154
+ );
155
+ expect(result.kind).toBe("fatal");
156
+ if (result.kind !== "fatal") return;
157
+ expect(result.error.message).toBe("expected `within` after `formerly`");
158
+ expect(result.error.labels).toEqual([{ start: 64, len: 8 }]);
159
+ expect(result.related).toHaveLength(1);
160
+ });
161
+
162
+ test("a span-less fatal (the OpError::message path) still parses", () => {
163
+ const result = parseValidateOutput(
164
+ json({ severity: "error", message: "rhai implementation has no script", spanned: false }, 2),
165
+ );
166
+ expect(result.kind).toBe("fatal");
167
+ });
168
+
169
+ test("an internal `at line N column M` tail is not passed through", () => {
170
+ const result = parseValidateOutput(
171
+ json({ severity: "error", message: "missing field `policies` at line 1 column 42", spanned: false }, 2),
172
+ );
173
+ expect(result.kind).toBe("fatal");
174
+ if (result.kind !== "fatal") return;
175
+ expect(result.error.message).toBe("missing field `policies`");
176
+ });
177
+ });
178
+
179
+ // ── The exit-2 ambiguity ───────────────────────────────────────────
180
+
181
+ describe("parseValidateOutput — exit 2 is not a verdict", () => {
182
+ test("an unknown flag exits 2 with nothing on stdout, and is unusable rather than rejected", () => {
183
+ // clap's own usage error. The published guide claims exit 1 for a bad
184
+ // flag; the binary spends 2 on it, the same code a rejected policy set
185
+ // gets. Reading the code alone would fail the build over a flag rename.
186
+ const result = parseValidateOutput(
187
+ run({
188
+ status: 2,
189
+ stdout: "",
190
+ stderr: "error: unexpected argument '--nosuchflag' found\n\nUsage: dogwood validate <POLICIES> --policy-schema <FILE>",
191
+ }),
192
+ );
193
+ expect(result.kind).toBe("unusable");
194
+ if (result.kind !== "unusable") return;
195
+ expect(result.reason).toContain("without a JSON report on stdout");
196
+ expect(result.reason).toContain("--nosuchflag");
197
+ });
198
+
199
+ test("a spawn failure is unusable, not a verdict", () => {
200
+ const result = parseValidateOutput(run({ status: null, error: "spawnSync dogwood ENOENT" }));
201
+ expect(result.kind).toBe("unusable");
202
+ if (result.kind !== "unusable") return;
203
+ expect(result.reason).toContain("ENOENT");
204
+ });
205
+
206
+ test("output that is not JSON at all is unusable", () => {
207
+ const result = parseValidateOutput(run({ status: 0, stdout: "OK: validation passed with no errors." }));
208
+ expect(result.kind).toBe("unusable");
209
+ });
210
+
211
+ test("JSON in neither shape is unusable", () => {
212
+ const result = parseValidateOutput(json({ something: "else" }, 2));
213
+ expect(result.kind).toBe("unusable");
214
+ if (result.kind !== "unusable") return;
215
+ expect(result.reason).toContain("neither the report nor the error shape");
216
+ });
217
+ });
218
+
219
+ // ── Rendering ──────────────────────────────────────────────────────
220
+
221
+ describe("formatDogwoodDiagnostic", () => {
222
+ test("renders code, message, help and byte ranges on one line", () => {
223
+ const text = formatDogwoodDiagnostic({
224
+ severity: "error",
225
+ code: "extension",
226
+ message: "temporal window `48h` exceeds the maximum allowed window `24h`",
227
+ labels: [{ start: 100, len: 20, message: "this window" }],
228
+ help: "raise max_window",
229
+ spanned: true,
230
+ });
231
+ expect(text).toBe(
232
+ "[extension] temporal window `48h` exceeds the maximum allowed window `24h` (raise max_window) at bytes 100-120: this window",
233
+ );
234
+ });
235
+
236
+ test("a span-less finding renders as just its message", () => {
237
+ expect(formatDogwoodDiagnostic({ severity: "error", message: "bare", labels: [], spanned: false })).toBe(
238
+ "bare",
239
+ );
240
+ });
241
+ });
242
+
243
+ // ── lower ──────────────────────────────────────────────────────────
244
+
245
+ describe("parseLowerOutput", () => {
246
+ test("reads the artifact shape, including the JSON schema as a string", () => {
247
+ const result = parseLowerOutput(run({ status: 0, stdout: JSON.stringify(LOWERED_READ_AFTER_LOGIN) }));
248
+ expect(result.kind).toBe("lowered");
249
+ if (result.kind !== "lowered") return;
250
+ expect(result.value.cedarPolicies).toContain("context.policy_0__temporal_0");
251
+ expect(result.value.temporalFields).toEqual(["policy_0__temporal_0"]);
252
+ // False because a temporal field was hoisted — the lowered Cedar needs
253
+ // dogwood at authorize time to fill the slot.
254
+ expect(result.value.selfContained).toBe(false);
255
+ expect(JSON.parse(result.value.cedarSchemaJson)).toHaveProperty("Drupe");
256
+ });
257
+
258
+ test("a failed lower comes back through the fatal channel", () => {
259
+ const result = parseLowerOutput(
260
+ json({ severity: "error", message: "unknown macro `once`", labels: [], spanned: false }, 2),
261
+ );
262
+ expect(result.kind).toBe("fatal");
263
+ });
264
+ });
265
+
266
+ // ── replay ─────────────────────────────────────────────────────────
267
+
268
+ describe("parseReplayOutput", () => {
269
+ /** The stream #1657 recorded for `read_after_login`, exit 0. */
270
+ const RECORDED = {
271
+ verdicts: [
272
+ { index: 0, timestamp: 0, verdict: "deny", determining_rules: [], errors: [] },
273
+ { index: 1, timestamp: 10, verdict: "allow", determining_rules: [0], errors: [] },
274
+ { index: 2, timestamp: 7200, verdict: "deny", determining_rules: [], errors: [] },
275
+ ],
276
+ };
277
+
278
+ test("reads the verdict stream, rules included", () => {
279
+ const result = parseReplayOutput(json(RECORDED));
280
+ expect(result.kind).toBe("replayed");
281
+ if (result.kind !== "replayed") return;
282
+ expect(result.verdicts).toHaveLength(3);
283
+ expect(result.verdicts[1]).toEqual({
284
+ index: 1,
285
+ timestamp: 10,
286
+ verdict: "allow",
287
+ determiningRules: [0],
288
+ errors: [],
289
+ });
290
+ });
291
+
292
+ test("every verdict DENY at exit 0 is a result, not a failure", () => {
293
+ // The sharpest edge in the replay contract: a nonzero exit means the trace
294
+ // or the policy set failed to load, never that a policy denied.
295
+ const allDeny = { verdicts: RECORDED.verdicts.map((v) => ({ ...v, verdict: "deny" })) };
296
+ const result = parseReplayOutput(json(allDeny));
297
+ expect(result.kind).toBe("replayed");
298
+ });
299
+
300
+ test("an unrecognised verdict reads as a deny rather than being dropped", () => {
301
+ // Dropping it would shift every later index; treating it as a permit would
302
+ // be a permission upstream never granted.
303
+ const result = parseReplayOutput(json({ verdicts: [{ index: 0, timestamp: 0, verdict: "maybe" }] }));
304
+ expect(result.kind).toBe("replayed");
305
+ if (result.kind !== "replayed") return;
306
+ expect(result.verdicts[0].verdict).toBe("deny");
307
+ });
308
+
309
+ test("a malformed trace line comes back through the fatal channel", () => {
310
+ const result = parseReplayOutput(
311
+ json({ severity: "error", message: "trace parse: line 1: timepoint must start with `@`", spanned: false }, 2),
312
+ );
313
+ expect(result.kind).toBe("fatal");
314
+ if (result.kind !== "fatal") return;
315
+ expect(result.error.message).toMatch(/timepoint must start with/);
316
+ });
317
+
318
+ test("no JSON at all is unusable, never a verdict", () => {
319
+ const result = parseReplayOutput(run({ status: 2, stderr: "error: unexpected argument '--nope'" }));
320
+ expect(result.kind).toBe("unusable");
321
+ });
322
+
323
+ test("per-evaluation errors are carried through in either shape", () => {
324
+ const result = parseReplayOutput(
325
+ json({
326
+ verdicts: [
327
+ { index: 0, timestamp: 0, verdict: "deny", determining_rules: [], errors: ["no script"] },
328
+ {
329
+ index: 1,
330
+ timestamp: 1,
331
+ verdict: "deny",
332
+ determining_rules: [],
333
+ errors: [{ message: "provider failed at line 1 column 4" }],
334
+ },
335
+ ],
336
+ }),
337
+ );
338
+ expect(result.kind).toBe("replayed");
339
+ if (result.kind !== "replayed") return;
340
+ expect(result.verdicts[0].errors).toEqual(["no script"]);
341
+ // The internal position is scrubbed for the same reason as everywhere else.
342
+ expect(result.verdicts[1].errors).toEqual(["provider failed"]);
343
+ });
344
+ });
345
+
346
+ describe("runDogwoodReplay", () => {
347
+ test("passes the trace as --trace beside the bundle, and asks for JSON", () => {
348
+ let seen: { args: string[]; trace: string } | undefined;
349
+ const runner: DogwoodRunner = (_binary, args) => {
350
+ const at = args.indexOf("--trace");
351
+ seen = { args, trace: readFileSync(args[at + 1], "utf-8") };
352
+ return run({ status: 0, stdout: JSON.stringify({ verdicts: [] }) });
353
+ };
354
+ configureDogwoodCli({ runner });
355
+
356
+ const result = runDogwoodReplay("/bin/dogwood", { policies: "p", policySchema: "s" }, "@0 A::Action::\"B\"::request()");
357
+ expect(result.kind).toBe("replayed");
358
+ expect(seen?.args[0]).toBe("replay");
359
+ expect(seen?.args.slice(-2)).toEqual(["--format", "json"]);
360
+ expect(seen?.trace).toBe('@0 A::Action::"B"::request()');
361
+ });
362
+
363
+ test("the scratch directory, trace included, does not outlive the run", () => {
364
+ let tracePath = "";
365
+ const runner: DogwoodRunner = (_binary, args) => {
366
+ tracePath = args[args.indexOf("--trace") + 1];
367
+ return run({ status: 0, stdout: JSON.stringify({ verdicts: [] }) });
368
+ };
369
+ configureDogwoodCli({ runner });
370
+ runDogwoodReplay("/bin/dogwood", { policies: "p", policySchema: "s" }, "@0 A::Action::\"B\"::request()");
371
+ expect(existsSync(tracePath)).toBe(false);
372
+ });
373
+
374
+ test("a throwing runner is an unusable run, not an exception", () => {
375
+ configureDogwoodCli({
376
+ runner: () => {
377
+ throw new Error("spawn blew up");
378
+ },
379
+ });
380
+ const result = runDogwoodReplay("/bin/dogwood", { policies: "p", policySchema: "s" }, "@0 x");
381
+ expect(result.kind).toBe("unusable");
382
+ });
383
+ });
384
+
385
+ // ── Invocation ─────────────────────────────────────────────────────
386
+
387
+ describe("the invocation itself", () => {
388
+ test("materializes the bundle and passes every schema half by path", () => {
389
+ let seen: { args: string[]; files: Record<string, string> } | undefined;
390
+ const runner: DogwoodRunner = (_binary, args) => {
391
+ const files: Record<string, string> = {};
392
+ for (let i = 0; i < args.length; i++) {
393
+ const value = args[i];
394
+ if (value.startsWith("/") || value.includes("chant-dogwood-")) {
395
+ try {
396
+ files[value.replace(/^.*\//, "")] = readFileSync(value, "utf-8");
397
+ } catch {
398
+ // not a path we wrote
399
+ }
400
+ }
401
+ }
402
+ seen = { args, files };
403
+ return json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] });
404
+ };
405
+ configureDogwoodCli({ runner });
406
+
407
+ const result = runDogwoodValidate("/opt/dogwood", {
408
+ policies: "permit (principal, action, resource);",
409
+ policySchema: "namespace Ns {}",
410
+ eventSchema: "decision event <A>::request {}",
411
+ macros: "def temporal once(?w, ?s) { formerly within ?w ?s };",
412
+ });
413
+
414
+ expect(result.kind).toBe("passed");
415
+ expect(seen?.args[0]).toBe("validate");
416
+ expect(seen?.args).toContain("--policy-schema");
417
+ expect(seen?.args).toContain("--event-schema");
418
+ expect(seen?.args).toContain("--macros");
419
+ expect(seen?.args.slice(-2)).toEqual(["--format", "json"]);
420
+ expect(seen?.files["policies.dw"]).toBe("permit (principal, action, resource);");
421
+ expect(seen?.files["events.dwschema"]).toBe("decision event <A>::request {}");
422
+ });
423
+
424
+ test("omits the service-schema flags a bundle does not carry", () => {
425
+ let args: string[] = [];
426
+ configureDogwoodCli({
427
+ runner: (_b, a) => {
428
+ args = a;
429
+ return json({ passed: true, passed_without_warnings: true, errors: [], warnings: [] });
430
+ },
431
+ });
432
+ runDogwoodValidate("dogwood", { policies: "// none", policySchema: "namespace Ns {}" });
433
+ expect(args).not.toContain("--event-schema");
434
+ expect(args).not.toContain("--macros");
435
+ expect(args).not.toContain("--providers");
436
+ });
437
+
438
+ test("never passes --emit, which the CLI ignores under --format json", () => {
439
+ let args: string[] = [];
440
+ configureDogwoodCli({
441
+ runner: (_b, a) => {
442
+ args = a;
443
+ return run({ status: 0, stdout: JSON.stringify(LOWERED_READ_AFTER_LOGIN) });
444
+ },
445
+ });
446
+ runDogwoodLower("dogwood", { policies: "// none", policySchema: "namespace Ns {}" });
447
+ expect(args[0]).toBe("lower");
448
+ expect(args).not.toContain("--emit");
449
+ });
450
+
451
+ test("a throwing runner is an unusable result, not an exception", () => {
452
+ configureDogwoodCli({
453
+ runner: () => {
454
+ throw new Error("boom");
455
+ },
456
+ });
457
+ const result = runDogwoodValidate("dogwood", { policies: "", policySchema: "" });
458
+ expect(result.kind).toBe("unusable");
459
+ });
460
+ });
461
+
462
+ // ── Locating the binary ────────────────────────────────────────────
463
+
464
+ describe("findDogwoodBinary", () => {
465
+ test("an explicit override wins and is reported as such", () => {
466
+ configureDogwoodCli({ binary: "/opt/dogwood/bin/dogwood" });
467
+ expect(findDogwoodBinary()).toEqual({ path: "/opt/dogwood/bin/dogwood", source: "override" });
468
+ });
469
+
470
+ test("a null override forces absence, so a machine that has one still tests the advisory", () => {
471
+ configureDogwoodCli({ binary: null });
472
+ expect(findDogwoodBinary()).toBeUndefined();
473
+ });
474
+
475
+ test("the environment variable is honoured when it names something executable", () => {
476
+ // `process.execPath` is a real executable on every platform the suite runs
477
+ // on, and standing in for the binary here costs nothing — resolution is an
478
+ // access check, not a run.
479
+ process.env[DOGWOOD_BINARY_ENV] = process.execPath;
480
+ expect(findDogwoodBinary()).toEqual({ path: process.execPath, source: "env" });
481
+ });
482
+
483
+ test("an environment variable pointing at nothing resolves past it", () => {
484
+ process.env[DOGWOOD_BINARY_ENV] = "/nonexistent/dogwood";
485
+ const found = findDogwoodBinary();
486
+ expect(found?.source).not.toBe("env");
487
+ });
488
+
489
+ test("cedar.dogwood.binary in a chant.config.json is read, walking up from the start directory", () => {
490
+ const root = mkdtempSync(join(tmpdir(), "chant-dogwood-config-"));
491
+ try {
492
+ writeFileSync(
493
+ join(root, "chant.config.json"),
494
+ JSON.stringify({ cedar: { dogwood: { binary: process.execPath } } }),
495
+ );
496
+ const nested = join(root, "src", "stack");
497
+ mkdirSync(nested, { recursive: true });
498
+ expect(findDogwoodBinary(nested)).toEqual({ path: process.execPath, source: "config" });
499
+ } finally {
500
+ rmSync(root, { recursive: true, force: true });
501
+ }
502
+ });
503
+
504
+ test("an unparseable chant.config.json is walked past rather than thrown from", () => {
505
+ const root = mkdtempSync(join(tmpdir(), "chant-dogwood-config-"));
506
+ try {
507
+ writeFileSync(join(root, "chant.config.json"), "{ not json");
508
+ expect(() => findDogwoodBinary(root)).not.toThrow();
509
+ } finally {
510
+ rmSync(root, { recursive: true, force: true });
511
+ }
512
+ });
513
+ });