@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,256 @@
1
+ /**
2
+ * The DWD walls.
3
+ *
4
+ * Each check reads the emitted text, so every case here is built as a
5
+ * `PostSynthContext` holding files rather than by driving the serializer —
6
+ * that is the same shape `chant audit` hands them when it runs over a
7
+ * checked-in `.dw` chant never wrote.
8
+ */
9
+ import { describe, test, expect } from "vitest";
10
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
11
+ import type { SerializerResult } from "@intentius/chant/serializer";
12
+ import { postSynthChecks } from "./index";
13
+ import { dwdc010 } from "./dwdc010";
14
+ import { dwdc011 } from "./dwdc011";
15
+ import { dwdc012 } from "./dwdc012";
16
+ import { dwds010 } from "./dwds010";
17
+ import { cedarAuditCatalog } from "../audit-catalog";
18
+ import { renderEventSchema, defaultEventSchema } from "../../dogwood/event-schema";
19
+ import { blankComments, temporalRegions } from "../../dogwood/scan";
20
+
21
+ function ctxOf(files: Record<string, string>): PostSynthContext {
22
+ const output: SerializerResult = { primary: "", files };
23
+ const outputs = new Map<string, string | SerializerResult>([["cedar", output]]);
24
+ return {
25
+ outputs,
26
+ entities: new Map(),
27
+ buildResult: { outputs, entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
28
+ };
29
+ }
30
+
31
+ const PINNED = renderEventSchema(defaultEventSchema());
32
+
33
+ function policy(body: string): string {
34
+ return `@id("p")\npermit (\n principal,\n action == Ns::Action::"Read",\n resource\n)\nwhen temporal {\n ${body}\n}\n;\n`;
35
+ }
36
+
37
+ // ── DWDC010 ────────────────────────────────────────────────────────
38
+
39
+ describe("DWDC010 — the event kind must be declared", () => {
40
+ test("a declared kind passes", () => {
41
+ const ctx = ctxOf({
42
+ "policies.dw": policy('formerly within 1h Ns::Action::"Login"::response{}'),
43
+ "events.dwschema": PINNED,
44
+ });
45
+ expect(dwdc010.check(ctx)).toEqual([]);
46
+ });
47
+
48
+ test("an undeclared kind is an error naming both the predicate and the declared set", () => {
49
+ const ctx = ctxOf({
50
+ "policies.dw": policy('formerly within 1h Ns::Action::"Login"::nosuchkind{}'),
51
+ "events.dwschema": PINNED,
52
+ });
53
+ const [finding, ...rest] = dwdc010.check(ctx);
54
+ expect(rest).toEqual([]);
55
+ expect(finding.severity).toBe("error");
56
+ expect(finding.message).toContain('Ns::Action::"Login"::nosuchkind');
57
+ expect(finding.message).toContain("error, request, response");
58
+ expect(finding.entity).toBe("policies.dw");
59
+ });
60
+
61
+ test("author-defined kinds pass against the schema that declares them", () => {
62
+ const custom = "decision event <A>::attempt {\n ...inputs(A),\n}\n";
63
+ const ctx = ctxOf({
64
+ "policies.dw": policy('formerly within 1h Ns::Action::"Login"::attempt{}'),
65
+ "events.dwschema": custom,
66
+ });
67
+ expect(dwdc010.check(ctx)).toEqual([]);
68
+ });
69
+
70
+ test("silent when no schema was emitted — the far end's defaults decide", () => {
71
+ const ctx = ctxOf({ "policies.dw": policy('formerly within 1h Ns::Action::"Login"::nosuchkind{}') });
72
+ expect(dwdc010.check(ctx)).toEqual([]);
73
+ });
74
+
75
+ test("a commented-out predicate is not a finding", () => {
76
+ const ctx = ctxOf({
77
+ "policies.dw": `// formerly within 1h Ns::Action::"Login"::nosuchkind{}\n${policy('formerly within 1h Ns::Action::"Login"::request{}')}`,
78
+ "events.dwschema": PINNED,
79
+ });
80
+ expect(dwdc010.check(ctx)).toEqual([]);
81
+ });
82
+
83
+ test("a Cedar when clause is not temporal source, so it is not scanned", () => {
84
+ const cedarOnly = `@id("p")\npermit (\n principal,\n action,\n resource\n)\nwhen { context.kind == "nosuchkind" };\n`;
85
+ const ctx = ctxOf({ "policies.dw": cedarOnly, "events.dwschema": PINNED });
86
+ expect(dwdc010.check(ctx)).toEqual([]);
87
+ });
88
+
89
+ test("the same predicate twice is reported once", () => {
90
+ const body = 'formerly within 1h Ns::Action::"Login"::bogus{} && previous within 1h Ns::Action::"Login"::bogus{}';
91
+ const ctx = ctxOf({ "policies.dw": policy(body), "events.dwschema": PINNED });
92
+ expect(dwdc010.check(ctx)).toHaveLength(1);
93
+ });
94
+ });
95
+
96
+ // ── DWDC011 ────────────────────────────────────────────────────────
97
+
98
+ describe("DWDC011 — the window must fit inside max_window", () => {
99
+ test("a window inside the default 24h cap passes with no schema emitted", () => {
100
+ const ctx = ctxOf({ "policies.dw": policy('formerly within 1h Ns::Action::"Login"::request{}') });
101
+ expect(dwdc011.check(ctx)).toEqual([]);
102
+ });
103
+
104
+ test("48h under the default 24h cap is an error even with no schema", () => {
105
+ const ctx = ctxOf({ "policies.dw": policy('formerly within 48h Ns::Action::"Login"::request{}') });
106
+ const [finding] = dwdc011.check(ctx);
107
+ expect(finding.severity).toBe("error");
108
+ expect(finding.message).toContain("looks back 48h");
109
+ expect(finding.message).toContain("24h");
110
+ expect(finding.message).toContain("no event schema was emitted");
111
+ });
112
+
113
+ test("raising max_window in the schema licenses the longer window", () => {
114
+ const raised = `max_window = 30d\n\n${PINNED.split("\n").slice(3).join("\n")}`;
115
+ const ctx = ctxOf({
116
+ "policies.dw": policy('formerly within 7d Ns::Action::"Login"::request{}'),
117
+ "events.dwschema": raised,
118
+ });
119
+ expect(dwdc011.check(ctx)).toEqual([]);
120
+ });
121
+
122
+ test("an emitted schema with no directive still caps at 24h", () => {
123
+ const ctx = ctxOf({
124
+ "policies.dw": policy('formerly within 25h Ns::Action::"Login"::request{}'),
125
+ "events.dwschema": PINNED,
126
+ });
127
+ expect(dwdc011.check(ctx)).toHaveLength(1);
128
+ });
129
+
130
+ test("the tightest of several schemas wins", () => {
131
+ const raised = `max_window = 30d\n\n${PINNED.split("\n").slice(3).join("\n")}`;
132
+ const ctx = ctxOf({
133
+ "policies.dw": policy('formerly within 7d Ns::Action::"Login"::request{}'),
134
+ "events.dwschema": raised,
135
+ "gateway.dwschema": PINNED,
136
+ });
137
+ expect(dwdc011.check(ctx)).toHaveLength(1);
138
+ });
139
+
140
+ test("a macro-call interval counts — it is the window the body resolves to", () => {
141
+ const ctx = ctxOf({ "policies.dw": policy('once(48h, Ns::Action::"Login"::request{})') });
142
+ const [finding] = dwdc011.check(ctx);
143
+ expect(finding.message).toContain("looks back 48h");
144
+ });
145
+
146
+ test("a window inside a macro definition is scanned too", () => {
147
+ const dw = `def temporal wide(?s) {\n formerly within 72h ?s\n};\n\n${policy('wide(Ns::Action::"Login"::request{})')}`;
148
+ const ctx = ctxOf({ "policies.dw": dw });
149
+ expect(dwdc011.check(ctx)).toHaveLength(1);
150
+ });
151
+
152
+ test("no policy file means nothing to judge", () => {
153
+ expect(dwdc011.check(ctxOf({ "events.dwschema": PINNED }))).toEqual([]);
154
+ });
155
+ });
156
+
157
+ // ── DWDC012 ────────────────────────────────────────────────────────
158
+
159
+ describe("DWDC012 — the window is not optional", () => {
160
+ test("a windowed operator passes", () => {
161
+ const ctx = ctxOf({ "policies.dw": policy('formerly within 1h Ns::Action::"Login"::request{}') });
162
+ expect(dwdc012.check(ctx)).toEqual([]);
163
+ });
164
+
165
+ test("a bare formerly is an error", () => {
166
+ const ctx = ctxOf({ "policies.dw": policy('formerly Ns::Action::"Login"::request{}') });
167
+ const [finding] = dwdc012.check(ctx);
168
+ expect(finding.severity).toBe("error");
169
+ expect(finding.message).toContain("`formerly` with no `within` window");
170
+ });
171
+
172
+ test("previous and since are held to the same rule", () => {
173
+ for (const operator of ["previous", "since"]) {
174
+ const ctx = ctxOf({ "policies.dw": policy(`${operator} Ns::Action::"Login"::request{}`) });
175
+ expect(dwdc012.check(ctx)[0]?.message).toContain(`\`${operator}\``);
176
+ }
177
+ });
178
+
179
+ test("a `within ?w` in a macro body is a window", () => {
180
+ const dw = `def temporal once(?w, ?s) {\n formerly within ?w ?s\n};\n`;
181
+ expect(dwdc012.check(ctxOf({ "policies.dw": dw }))).toEqual([]);
182
+ });
183
+
184
+ test("a Cedar attribute named `since` outside a temporal block is not the operator", () => {
185
+ const cedarOnly = `@id("p")\npermit (\n principal,\n action,\n resource\n)\nwhen { context.since > 3 };\n`;
186
+ expect(dwdc012.check(ctxOf({ "policies.dw": cedarOnly }))).toEqual([]);
187
+ });
188
+ });
189
+
190
+ // ── DWDS010 ────────────────────────────────────────────────────────
191
+
192
+ describe("DWDS010 — an unpinned schema widens every predicate", () => {
193
+ test("the default shape's pin satisfies it", () => {
194
+ expect(dwds010.check(ctxOf({ "events.dwschema": PINNED }))).toEqual([]);
195
+ });
196
+
197
+ test("a schema with no pin is a report-only warning", () => {
198
+ const unpinned = renderEventSchema(defaultEventSchema({ pinCallerPrincipal: false }));
199
+ const [finding] = dwds010.check(ctxOf({ "events.dwschema": unpinned }));
200
+ expect(finding.severity).toBe("warning");
201
+ expect(finding.message).toContain("declares no pinned field");
202
+ expect(finding.entity).toBe("events.dwschema");
203
+ });
204
+
205
+ test("a file with no declarations at all is not the pin's problem", () => {
206
+ expect(dwds010.check(ctxOf({ "events.dwschema": "// nothing here\n" }))).toEqual([]);
207
+ });
208
+ });
209
+
210
+ // ── Registration ───────────────────────────────────────────────────
211
+
212
+ describe("registration", () => {
213
+ test("every DWD check is auto-discovered into the committed barrel", () => {
214
+ const ids = postSynthChecks.map((c) => c.id).filter((id) => id.startsWith("DWD"));
215
+ expect(ids.sort()).toEqual(["DWDC010", "DWDC011", "DWDC012", "DWDE010", "DWDE011", "DWDS010"]);
216
+ });
217
+
218
+ test("every DWD check has a catalog entry, so `chant audit` can title it", () => {
219
+ for (const check of postSynthChecks) {
220
+ if (!check.id.startsWith("DWD")) continue;
221
+ const meta = cedarAuditCatalog[check.id];
222
+ expect(meta, `${check.id} is catalogued`).toBeDefined();
223
+ expect(meta.title.length).toBeGreaterThan(0);
224
+ expect(meta.remediation.length).toBeGreaterThan(0);
225
+ }
226
+ });
227
+
228
+ test("the catalog carries no DWD entry without a check behind it", () => {
229
+ const shipped = new Set(postSynthChecks.map((c) => c.id));
230
+ const stale = Object.keys(cedarAuditCatalog).filter((id) => id.startsWith("DWD") && !shipped.has(id));
231
+ expect(stale).toEqual([]);
232
+ });
233
+ });
234
+
235
+ // ── The scanner itself ─────────────────────────────────────────────
236
+
237
+ describe("the text scanner", () => {
238
+ test("a // inside a string literal is not a comment", () => {
239
+ const text = 'when temporal { Ns::Action::"a//b"::request{} }';
240
+ expect(blankComments(text)).toBe(text);
241
+ });
242
+
243
+ test("a comment is blanked without moving any offset", () => {
244
+ const text = 'x // hidden\ny';
245
+ const blanked = blankComments(text);
246
+ expect(blanked).toHaveLength(text.length);
247
+ expect(blanked).toBe("x \ny");
248
+ });
249
+
250
+ test("a temporal block is extracted through its nested braces", () => {
251
+ const text = 'when temporal { formerly within 1h Ns::Action::"A"::request{ input.x: 1 } }';
252
+ expect(temporalRegions(text)).toEqual([
253
+ ' formerly within 1h Ns::Action::"A"::request{ input.x: 1 } ',
254
+ ]);
255
+ });
256
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * DWDC010: a temporal predicate must name an event kind the schema declares
3
+ *
4
+ * Upstream rejects this outright — a predicate naming an undeclared kind fails
5
+ * `dogwood validate` with code `extension`: "predicate
6
+ * `Drupe::Action::\"Read\"::nosuchkind` does not name a declared event (no
7
+ * event kind `nosuchkind` derived for action `Read`)". Catching it in
8
+ * TypeScript is the point of the split epic #1646 drew: everything answerable
9
+ * without the binary runs always and gates, and this one is answerable.
10
+ *
11
+ * The check is on the event *kind*, not the action, and that is not a
12
+ * shortcut. Upstream's `.dwschema` grammar declares events against a symbolic
13
+ * action binder — `decision event <A>::request { … }` — so the file names no
14
+ * actions at all and cannot be asked whether one exists. The action half of
15
+ * that upstream error comes from the Cedar action schema
16
+ * (`--policy-schema`), which is #1659's CLI-gated leg.
17
+ *
18
+ * Silent when no `.dwschema` was emitted: with none supplied,
19
+ * `ServiceSchema::defaults()` decides the kinds at the far end, and guessing
20
+ * that a project's out-of-band schema matches upstream's default would fail
21
+ * builds for a policy set that is fine.
22
+ */
23
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
24
+ import { dogwoodPolicyFiles, dogwoodSchemaFiles, readEventSchema, scanPredicates } from "../../dogwood/scan";
25
+
26
+ export const dwdc010: PostSynthCheck = {
27
+ id: "DWDC010",
28
+ description: "A dogwood temporal predicate names an event kind the emitted .dwschema declares",
29
+
30
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
31
+ const schemas = dogwoodSchemaFiles(ctx);
32
+ if (schemas.length === 0) return [];
33
+
34
+ const declared = new Set<string>();
35
+ for (const schema of schemas) {
36
+ for (const kind of readEventSchema(schema.text).kinds) declared.add(kind);
37
+ }
38
+ if (declared.size === 0) return [];
39
+
40
+ const known = [...declared].sort().join(", ");
41
+ const diagnostics: PostSynthDiagnostic[] = [];
42
+
43
+ for (const policies of dogwoodPolicyFiles(ctx)) {
44
+ const seen = new Set<string>();
45
+ for (const ref of scanPredicates(policies.text)) {
46
+ if (declared.has(ref.kind)) continue;
47
+ const key = `${ref.action}::${ref.kind}`;
48
+ if (seen.has(key)) continue;
49
+ seen.add(key);
50
+ diagnostics.push({
51
+ checkId: "DWDC010",
52
+ severity: "error",
53
+ message: `Dogwood policy set "${policies.source}" has a temporal predicate on ${key}, but the emitted event schema declares no event kind "${ref.kind}" (declared: ${known}).`,
54
+ entity: policies.source,
55
+ lexicon: policies.lexicon,
56
+ });
57
+ }
58
+ }
59
+
60
+ return diagnostics;
61
+ },
62
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * DWDC011: a temporal window must fit inside the schema's `max_window`
3
+ *
4
+ * `formerly within 48h` under a 24h cap is an upstream validation error —
5
+ * "temporal window `48h` exceeds the maximum allowed window `24h` set by the
6
+ * event schema's `max_window`" — and it is arithmetic, so it needs no binary.
7
+ *
8
+ * Unlike DWDC010 this fires with no `.dwschema` emitted, because the cap
9
+ * applies either way: `ServiceSchema::defaults()` caps look-back at 24h, and
10
+ * so does an emitted schema that omits the directive. Where several schemas
11
+ * are emitted the tightest cap wins — a window one consumer accepts and
12
+ * another rejects is still a window that will be rejected.
13
+ *
14
+ * Macro-call intervals count. `once(48h, …)` expands to `formerly within 48h`
15
+ * through the default library's `within ?w`, so it looks back exactly as far.
16
+ */
17
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
18
+ import {
19
+ dogwoodPolicyFiles,
20
+ dogwoodSchemaFiles,
21
+ effectiveMaxWindowSeconds,
22
+ readEventSchema,
23
+ scanWindows,
24
+ } from "../../dogwood/scan";
25
+
26
+ export const dwdc011: PostSynthCheck = {
27
+ id: "DWDC011",
28
+ description: "A dogwood temporal window stays inside the event schema's max_window cap",
29
+
30
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
31
+ const policyFiles = dogwoodPolicyFiles(ctx);
32
+ if (policyFiles.length === 0) return [];
33
+
34
+ const schemas = dogwoodSchemaFiles(ctx).map((s) => readEventSchema(s.text));
35
+ const cap = effectiveMaxWindowSeconds(schemas);
36
+ const source =
37
+ schemas.length === 0
38
+ ? "upstream's default cap (no event schema was emitted)"
39
+ : `the emitted event schema's max_window`;
40
+
41
+ const diagnostics: PostSynthDiagnostic[] = [];
42
+
43
+ for (const policies of policyFiles) {
44
+ const reported = new Set<string>();
45
+ for (const window of scanWindows(policies.text)) {
46
+ if (window.seconds <= cap.seconds) continue;
47
+ if (reported.has(window.text)) continue;
48
+ reported.add(window.text);
49
+ diagnostics.push({
50
+ checkId: "DWDC011",
51
+ severity: "error",
52
+ message: `Dogwood policy set "${policies.source}" looks back ${window.text}, past the ${cap.text} allowed by ${source}. Raise max_window in the event schema, or shorten the window.`,
53
+ entity: policies.source,
54
+ lexicon: policies.lexicon,
55
+ });
56
+ }
57
+ }
58
+
59
+ return diagnostics;
60
+ },
61
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * DWDC012: `formerly`, `previous` and `since` carry a mandatory window
3
+ *
4
+ * Upstream's grammar makes `within` non-optional on all three:
5
+ *
6
+ * ```
7
+ * once_op = { "formerly" ~ within ~ atom }
8
+ * previous_op = { "previous" ~ within ~ atom }
9
+ * conjunct_or_since = { neg_conjunct ~ ("since" ~ within ~ atom)? }
10
+ * ```
11
+ *
12
+ * The typed builders make this unrepresentable — `formerly(window, body)` has
13
+ * nowhere to put a missing window — which is most of the fix. This check is
14
+ * the other part: `raw()` exists as an escape hatch, a hand-written `.dw` can
15
+ * be built by any other tool, and `chant audit` runs over files chant never
16
+ * emitted. A wall that only holds for the path that cannot breach it is not
17
+ * a wall.
18
+ */
19
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
20
+ import { dogwoodPolicyFiles, scanWindowlessOperators } from "../../dogwood/scan";
21
+
22
+ export const dwdc012: PostSynthCheck = {
23
+ id: "DWDC012",
24
+ description: "A dogwood formerly/previous/since operator carries its mandatory within window",
25
+
26
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
27
+ const diagnostics: PostSynthDiagnostic[] = [];
28
+
29
+ for (const policies of dogwoodPolicyFiles(ctx)) {
30
+ const seen = new Set<string>();
31
+ for (const operator of scanWindowlessOperators(policies.text)) {
32
+ if (seen.has(operator)) continue;
33
+ seen.add(operator);
34
+ diagnostics.push({
35
+ checkId: "DWDC012",
36
+ severity: "error",
37
+ message: `Dogwood policy set "${policies.source}" uses \`${operator}\` with no \`within\` window. All three past-only operators require one — write \`${operator} within <n><s|m|h|d> …\`.`,
38
+ entity: policies.source,
39
+ lexicon: policies.lexicon,
40
+ });
41
+ }
42
+ }
43
+
44
+ return diagnostics;
45
+ },
46
+ };