@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,141 @@
1
+ /**
2
+ * The dogwood entity model: a temporal policy, an event schema, a macro library.
3
+ *
4
+ * `Dogwood::TemporalPolicy` is `Cedar::Policy` plus the two clause forms
5
+ * upstream's policy grammar adds. Its head — annotations, effect, the three
6
+ * scope positions — is Cedar's, byte for byte, which is why `./serialize.ts`
7
+ * renders it with the cedar serializer's own `renderPolicyHead` rather than a
8
+ * copy of it. Only the `cond` rule differs:
9
+ *
10
+ * ```
11
+ * cond = { cond_kw ~ (extension_marker | guardrails_tag? ~ "{" ~ expr ~ "}") }
12
+ * ```
13
+ *
14
+ * — so a clause is `when { … }`, `when guardrails { … }`, or
15
+ * `when temporal { … }`, and the same three under `unless`. `guardrails` is
16
+ * transparent sugar for a bare Cedar expression (upstream discards the tag
17
+ * during lowering); `temporal { … }` is a genuine sub-language dispatched to a
18
+ * different parser.
19
+ *
20
+ * These are not schema-driven the way `Cedar::Policy`'s generated class is.
21
+ * Codegen's input is the project's `.cedarschema`, which says nothing about
22
+ * event kinds or temporal operators, so the dialect's classes are written here
23
+ * and typed against the builders in `./temporal.ts`.
24
+ */
25
+
26
+ import type { Declarable } from "@intentius/chant/declarable";
27
+ import { createResource } from "@intentius/chant/runtime";
28
+ import type { CedarEffect, CedarScope } from "../serializer";
29
+ import type { TemporalCondition } from "./temporal";
30
+ import type { EventSchema } from "./event-schema";
31
+ import type { MacroDefinition } from "./macros";
32
+
33
+ /** The lexicon these entities belong to — dogwood is a dialect of cedar, not a lexicon. */
34
+ export const DOGWOOD_LEXICON = "cedar";
35
+
36
+ /** The `entityType` of a temporal policy. */
37
+ export const DOGWOOD_POLICY_TYPE = "Dogwood::TemporalPolicy";
38
+
39
+ /** The `entityType` of an event schema. */
40
+ export const DOGWOOD_EVENT_SCHEMA_TYPE = "Dogwood::EventSchema";
41
+
42
+ /** The `entityType` of a macro library. */
43
+ export const DOGWOOD_MACRO_LIBRARY_TYPE = "Dogwood::MacroLibrary";
44
+
45
+ /** The `.dw` policy set written beside the cedar outputs. */
46
+ export const DOGWOOD_POLICY_FILENAME = "policies.dw";
47
+
48
+ /** The `.dwschema` event schema — what `dogwood validate --event-schema` reads. */
49
+ export const DOGWOOD_EVENT_SCHEMA_FILENAME = "events.dwschema";
50
+
51
+ /** The macro library — what `dogwood validate --macros` reads. */
52
+ export const DOGWOOD_MACRO_FILENAME = "macros.dw";
53
+
54
+ /**
55
+ * The props of a `Dogwood::TemporalPolicy`.
56
+ *
57
+ * The first six mirror `CedarPolicyProps` exactly. The rest are the dialect's.
58
+ */
59
+ export interface TemporalPolicyProps {
60
+ /** Defaults to `permit` when omitted. */
61
+ effect?: CedarEffect;
62
+ /** Defaults to unconstrained (`{}`) when omitted. */
63
+ principal?: CedarScope;
64
+ /** Defaults to unconstrained (`{}`) when omitted. */
65
+ action?: CedarScope;
66
+ /** Defaults to unconstrained (`{}`) when omitted. */
67
+ resource?: CedarScope;
68
+ /** Cedar expression strings, each emitted as its own `when { … }` clause. */
69
+ when?: string[];
70
+ /** Cedar expression strings, each emitted as its own `unless { … }` clause. */
71
+ unless?: string[];
72
+ /** Emitted as `@key("value")`; an explicit `id` wins over the logical name. */
73
+ annotations?: Record<string, string>;
74
+
75
+ /** Each emitted as its own `when temporal { … }` clause. */
76
+ whenTemporal?: TemporalCondition[];
77
+ /** Each emitted as its own `unless temporal { … }` clause. */
78
+ unlessTemporal?: TemporalCondition[];
79
+ /**
80
+ * Cedar expression strings emitted as `when guardrails { … }`.
81
+ *
82
+ * The tag carries no separate grammar — upstream parses the body as an
83
+ * ordinary Cedar expression and discards the tag when lowering. It marks a
84
+ * clause as a guardrail for a human reader and for whatever reads the source
85
+ * after chant; it does not change what the policy means.
86
+ */
87
+ whenGuardrails?: string[];
88
+ /** As {@link whenGuardrails}, negated. */
89
+ unlessGuardrails?: string[];
90
+ }
91
+
92
+ /**
93
+ * A temporal policy. Declare one per policy, the same way as `Cedar::Policy`:
94
+ *
95
+ * ```ts
96
+ * export const readAfterLogin = new TemporalPolicy({
97
+ * action: { eq: 'Drupe::Action::"Read"' },
98
+ * whenTemporal: [
99
+ * formerly("1h", predicate('Drupe::Action::"Login"', "response", {
100
+ * "input.user": ctx("input.user"),
101
+ * })),
102
+ * ],
103
+ * });
104
+ * ```
105
+ */
106
+ export const TemporalPolicy = createResource(DOGWOOD_POLICY_TYPE, DOGWOOD_LEXICON, {}) as unknown as new (
107
+ props: TemporalPolicyProps,
108
+ ) => Declarable;
109
+
110
+ /** The props of a `Dogwood::EventSchema`. */
111
+ export interface EventSchemaProps {
112
+ /** Built with `eventSchema()` or `defaultEventSchema()`. */
113
+ schema: EventSchema;
114
+ /** Defaults to {@link DOGWOOD_EVENT_SCHEMA_FILENAME}. */
115
+ filename?: string;
116
+ }
117
+
118
+ /** The service half of the schema, emitted as `.dwschema` text. */
119
+ export const TemporalEventSchema = createResource(DOGWOOD_EVENT_SCHEMA_TYPE, DOGWOOD_LEXICON, {}) as unknown as new (
120
+ props: EventSchemaProps,
121
+ ) => Declarable;
122
+
123
+ /** The props of a `Dogwood::MacroLibrary`. */
124
+ export interface MacroLibraryProps {
125
+ macros: MacroDefinition[];
126
+ /** Defaults to {@link DOGWOOD_MACRO_FILENAME}. Ignored when `inline` is set. */
127
+ filename?: string;
128
+ /**
129
+ * Emit the definitions at the top of the policy set instead of into their own
130
+ * file.
131
+ *
132
+ * A policy set's own `def` shadows a same-named library macro, so inlining is
133
+ * how a project stops depending on whoever runs the CLI passing `--macros`.
134
+ */
135
+ inline?: boolean;
136
+ }
137
+
138
+ /** A `def cedar` / `def temporal` library, emitted as `.dw` text. */
139
+ export const TemporalMacroLibrary = createResource(DOGWOOD_MACRO_LIBRARY_TYPE, DOGWOOD_LEXICON, {}) as unknown as new (
140
+ props: MacroLibraryProps,
141
+ ) => Declarable;
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Reading the emitted dogwood artifacts back, for the DWD post-synth checks.
3
+ *
4
+ * The checks judge the *text*, not the in-memory model, for the same reason
5
+ * the CED checks judge `policies.cedar.json`: `chant audit` runs over a
6
+ * checked-in artifact chant did not write, and a wall that only fires on
7
+ * chant's own output is not a wall. It also means the typed builders and the
8
+ * walls are independent — DWDC012 catches a windowless `formerly` even though
9
+ * the builders cannot construct one, because `raw()` and a hand-written `.dw`
10
+ * both can.
11
+ *
12
+ * This is scanning, not parsing. Upstream owns the parser and #1659 owns
13
+ * shelling to it; what is here is the subset of the surface that answers the
14
+ * three questions the walls ask, plus enough comment handling not to be fooled
15
+ * by a commented-out clause.
16
+ */
17
+
18
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
19
+ import { DEFAULT_MAX_WINDOW, windowSeconds, type TimeUnit } from "./window";
20
+ import { DOGWOOD_EVENT_SCHEMA_SUFFIX, DOGWOOD_POLICY_SUFFIX } from "./serialize";
21
+
22
+ /** One emitted artifact, with everything needed to name it in a finding. */
23
+ export interface DogwoodArtifact {
24
+ /** The lexicon output it came from. */
25
+ lexicon: string;
26
+ /** The filename, which is what a finding points the reader at. */
27
+ source: string;
28
+ /** The file's text. */
29
+ text: string;
30
+ }
31
+
32
+ /** Every `.dw` policy file in the build output. */
33
+ export function dogwoodPolicyFiles(ctx: PostSynthContext): DogwoodArtifact[] {
34
+ return filesWithSuffix(ctx, DOGWOOD_POLICY_SUFFIX);
35
+ }
36
+
37
+ /** Every `.dwschema` event-schema file in the build output. */
38
+ export function dogwoodSchemaFiles(ctx: PostSynthContext): DogwoodArtifact[] {
39
+ return filesWithSuffix(ctx, DOGWOOD_EVENT_SCHEMA_SUFFIX);
40
+ }
41
+
42
+ function filesWithSuffix(ctx: PostSynthContext, suffix: string): DogwoodArtifact[] {
43
+ const found: DogwoodArtifact[] = [];
44
+ for (const [lexicon, output] of ctx.outputs) {
45
+ if (typeof output === "string") continue;
46
+ for (const [filename, content] of Object.entries(output.files ?? {})) {
47
+ if (typeof content !== "string") continue;
48
+ if (!filename.endsWith(suffix)) continue;
49
+ found.push({ lexicon, source: filename, text: content });
50
+ }
51
+ }
52
+ return found;
53
+ }
54
+
55
+ // ── Comments ──────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Blank out `//` comments, leaving string literals intact.
59
+ *
60
+ * String literals stay because a predicate's action id lives inside one
61
+ * (`Drupe::Action::"Read"`), and blanking those would blind every scan below.
62
+ * The walk is string-aware in the other direction too: a `//` inside a quoted
63
+ * value (a URL, say) is not a comment.
64
+ *
65
+ * Comment bytes become spaces so every offset in the result still lines up
66
+ * with the original file.
67
+ */
68
+ export function blankComments(text: string): string {
69
+ const out = text.split("");
70
+ let inString = false;
71
+ for (let i = 0; i < text.length; i++) {
72
+ const ch = text[i];
73
+ if (inString) {
74
+ if (ch === "\\") {
75
+ i++;
76
+ continue;
77
+ }
78
+ if (ch === '"') inString = false;
79
+ continue;
80
+ }
81
+ if (ch === '"') {
82
+ inString = true;
83
+ continue;
84
+ }
85
+ if (ch === "/" && text[i + 1] === "/") {
86
+ while (i < text.length && text[i] !== "\n") {
87
+ out[i] = " ";
88
+ i++;
89
+ }
90
+ }
91
+ }
92
+ return out.join("");
93
+ }
94
+
95
+ // ── Temporal regions ──────────────────────────────────────────────
96
+
97
+ /**
98
+ * The stretches of a `.dw` file that the temporal sub-parser reads: every
99
+ * `temporal { … }` marker body, and every `def temporal … { … }` macro body.
100
+ *
101
+ * Scanning only these is what keeps a Cedar `when { … }` clause from being
102
+ * mistaken for temporal source — `context.retryWindow == 3` should not read as
103
+ * a window, and a Cedar attribute named `since` is not the `since` operator.
104
+ */
105
+ export function temporalRegions(text: string): string[] {
106
+ const source = blankComments(text);
107
+ const regions: string[] = [];
108
+
109
+ const markers = /\btemporal\s*\{/g;
110
+ for (let m = markers.exec(source); m !== null; m = markers.exec(source)) {
111
+ const body = matchedBlock(source, m.index + m[0].length - 1);
112
+ if (body !== undefined) regions.push(body);
113
+ }
114
+
115
+ const macros = /\bdef\s+temporal\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*\{/g;
116
+ for (let m = macros.exec(source); m !== null; m = macros.exec(source)) {
117
+ const body = matchedBlock(source, m.index + m[0].length - 1);
118
+ if (body !== undefined) regions.push(body);
119
+ }
120
+
121
+ return regions;
122
+ }
123
+
124
+ /** The text between `text[open]` (a `{`) and its matching `}`, or undefined. */
125
+ function matchedBlock(text: string, open: number): string | undefined {
126
+ let depth = 0;
127
+ let inString = false;
128
+ for (let i = open; i < text.length; i++) {
129
+ const ch = text[i];
130
+ if (inString) {
131
+ if (ch === "\\") i++;
132
+ else if (ch === '"') inString = false;
133
+ continue;
134
+ }
135
+ if (ch === '"') inString = true;
136
+ else if (ch === "{") depth++;
137
+ else if (ch === "}") {
138
+ depth--;
139
+ if (depth === 0) return text.slice(open + 1, i);
140
+ }
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ // ── What the walls ask ────────────────────────────────────────────
146
+
147
+ /** One `Ns::Action::"Name"::kind` predicate head found in temporal source. */
148
+ export interface TemporalPredicateRef {
149
+ /** The qualified action, quoted id and all. */
150
+ action: string;
151
+ /** The event kind segment after the quoted id. */
152
+ kind: string;
153
+ }
154
+
155
+ const PREDICATE = /([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*::"[^"]*")::([A-Za-z_][A-Za-z0-9_]*)/g;
156
+
157
+ /** Every temporal predicate head in a `.dw` file, in source order. */
158
+ export function scanPredicates(text: string): TemporalPredicateRef[] {
159
+ const refs: TemporalPredicateRef[] = [];
160
+ for (const region of temporalRegions(text)) {
161
+ for (let m = PREDICATE.exec(region); m !== null; m = PREDICATE.exec(region)) {
162
+ refs.push({ action: m[1], kind: m[2] });
163
+ }
164
+ PREDICATE.lastIndex = 0;
165
+ }
166
+ return refs;
167
+ }
168
+
169
+ /** One window literal found in temporal source. */
170
+ export interface WindowRef {
171
+ /** As written: `48h`. */
172
+ text: string;
173
+ seconds: number;
174
+ /** `within` for an operator's window, `argument` for a bare macro-call interval. */
175
+ position: "within" | "argument";
176
+ }
177
+
178
+ const WITHIN_WINDOW = /\bwithin\s+(\d+)([smhd])\b/g;
179
+ /** A bare interval in macro-call argument position: `once(1h, …)`, `f(a, 30m)`. */
180
+ const ARGUMENT_WINDOW = /[(,]\s*(\d+)([smhd])\s*(?=[,)])/g;
181
+
182
+ /**
183
+ * Every window literal in a `.dw` file's temporal source.
184
+ *
185
+ * Both shapes count against `max_window`: a macro-call interval is the window
186
+ * a `within ?w` in the macro body resolves to, so `once(48h, …)` looks back
187
+ * exactly as far as `formerly within 48h` does.
188
+ */
189
+ export function scanWindows(text: string): WindowRef[] {
190
+ const windows: WindowRef[] = [];
191
+ for (const region of temporalRegions(text)) {
192
+ for (let m = WITHIN_WINDOW.exec(region); m !== null; m = WITHIN_WINDOW.exec(region)) {
193
+ windows.push(windowRef(m[1], m[2], "within"));
194
+ }
195
+ WITHIN_WINDOW.lastIndex = 0;
196
+ for (let m = ARGUMENT_WINDOW.exec(region); m !== null; m = ARGUMENT_WINDOW.exec(region)) {
197
+ windows.push(windowRef(m[1], m[2], "argument"));
198
+ }
199
+ ARGUMENT_WINDOW.lastIndex = 0;
200
+ }
201
+ return windows;
202
+ }
203
+
204
+ function windowRef(value: string, unit: string, position: WindowRef["position"]): WindowRef {
205
+ const w = { value: Number(value), unit: unit as TimeUnit };
206
+ return { text: `${w.value}${w.unit}`, seconds: windowSeconds(w), position };
207
+ }
208
+
209
+ /**
210
+ * Every `formerly` / `previous` / `since` that is not followed by a `within`.
211
+ *
212
+ * Unrepresentable through the builders — {@link formerly} and its siblings all
213
+ * take the window as an argument — but reachable through `raw()`, through a
214
+ * hand-written `.dw`, and through an audit of a file chant never wrote.
215
+ */
216
+ export function scanWindowlessOperators(text: string): string[] {
217
+ const found: string[] = [];
218
+ const pattern = /\b(formerly|previous|since)\b(?!\s+within\b)/g;
219
+ for (const region of temporalRegions(text)) {
220
+ for (let m = pattern.exec(region); m !== null; m = pattern.exec(region)) {
221
+ found.push(m[1]);
222
+ }
223
+ pattern.lastIndex = 0;
224
+ }
225
+ return found;
226
+ }
227
+
228
+ // ── The event schema side ─────────────────────────────────────────
229
+
230
+ /** What a `.dwschema` file says, as far as the walls need to know. */
231
+ export interface EventSchemaFacts {
232
+ /** Every declared event kind. */
233
+ kinds: string[];
234
+ /** The `max_window` directive in seconds, or upstream's 24h default when absent. */
235
+ maxWindowSeconds: number;
236
+ /** As written (`30d`), or undefined when the directive is absent. */
237
+ maxWindowText?: string;
238
+ /** True when at least one field carries a `pin … = …`. */
239
+ hasPin: boolean;
240
+ }
241
+
242
+ const EVENT_DECL = /\bevent\s*<\s*[A-Za-z_][A-Za-z0-9_]*\s*>\s*::\s*([A-Za-z_][A-Za-z0-9_]*)/g;
243
+ const MAX_WINDOW = /\bmax_window\s*=\s*(\d+)([smhd])\b/;
244
+ const PINNED_FIELD = /\bpin\s+[A-Za-z_][A-Za-z0-9_]*\s*:/;
245
+
246
+ /** Read a `.dwschema` file's declarations. */
247
+ export function readEventSchema(text: string): EventSchemaFacts {
248
+ const source = blankComments(text);
249
+
250
+ const kinds: string[] = [];
251
+ for (let m = EVENT_DECL.exec(source); m !== null; m = EVENT_DECL.exec(source)) {
252
+ if (!kinds.includes(m[1])) kinds.push(m[1]);
253
+ }
254
+ EVENT_DECL.lastIndex = 0;
255
+
256
+ const max = MAX_WINDOW.exec(source);
257
+ return {
258
+ kinds,
259
+ maxWindowSeconds: max
260
+ ? windowSeconds({ value: Number(max[1]), unit: max[2] as TimeUnit })
261
+ : windowSeconds(DEFAULT_MAX_WINDOW),
262
+ maxWindowText: max ? `${max[1]}${max[2]}` : undefined,
263
+ hasPin: PINNED_FIELD.test(source),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * The look-back cap in force for a build.
269
+ *
270
+ * With no emitted schema, `ServiceSchema::defaults()` applies and the cap is
271
+ * 24h. With several, the tightest one wins — a window legal under one schema
272
+ * and not another is a window some consumer will reject.
273
+ */
274
+ export function effectiveMaxWindowSeconds(schemas: EventSchemaFacts[]): { seconds: number; text: string } {
275
+ if (schemas.length === 0) {
276
+ return {
277
+ seconds: windowSeconds(DEFAULT_MAX_WINDOW),
278
+ text: `${DEFAULT_MAX_WINDOW.value}${DEFAULT_MAX_WINDOW.unit}`,
279
+ };
280
+ }
281
+ let tightest = schemas[0];
282
+ for (const s of schemas) if (s.maxWindowSeconds < tightest.maxWindowSeconds) tightest = s;
283
+ return {
284
+ seconds: tightest.maxWindowSeconds,
285
+ text: tightest.maxWindowText ?? `${DEFAULT_MAX_WINDOW.value}${DEFAULT_MAX_WINDOW.unit}`,
286
+ };
287
+ }