@ak--47/dungeon-master 1.4.2 → 1.4.3

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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Schema validation for dungeon output.
3
+ *
4
+ * Derives the expected set of property keys per event type from a dungeon
5
+ * config, then compares against actual output to catch hooks that introduce
6
+ * undeclared columns ("flag stamping").
7
+ *
8
+ * Rule: a hook-introduced column is acceptable (PASS) only if it appears on
9
+ * 100% of events of that type. Partial coverage is a FAIL.
10
+ */
11
+
12
+ /** @typedef {import('../../types.js').Dungeon} Dungeon */
13
+
14
+ const CORE_KEYS = new Set(['event', 'time', 'insert_id', 'user_id']);
15
+ const LOCATION_KEYS = ['city', 'region', 'country', 'country_code'];
16
+ const DEVICE_KEYS = ['model', 'screen_height', 'screen_width', 'os', 'Platform', 'carrier', 'radio'];
17
+ const CAMPAIGN_KEYS = ['utm_source', 'utm_campaign', 'utm_medium', 'utm_content', 'utm_term'];
18
+
19
+ /**
20
+ * Derive the expected property keys per event type from config alone.
21
+ * @param {Dungeon} config
22
+ * @returns {Map<string, Set<string>>} eventName → set of expected property keys
23
+ */
24
+ export function deriveExpectedSchema(config) {
25
+ const globalKeys = new Set(CORE_KEYS);
26
+ /** @type {Map<string, Set<string>>} */
27
+ const perType = new Map();
28
+
29
+ if (config.avgDevicePerUser > 0 || config.hasAnonIds) {
30
+ globalKeys.add('device_id');
31
+ }
32
+ if (config.hasSessionIds) {
33
+ globalKeys.add('session_id');
34
+ }
35
+
36
+ if (config.superProps) {
37
+ for (const key of Object.keys(config.superProps)) {
38
+ globalKeys.add(key);
39
+ }
40
+ }
41
+
42
+ if (config.hasLocation) {
43
+ for (const k of LOCATION_KEYS) globalKeys.add(k);
44
+ }
45
+ if (config.hasBrowser) {
46
+ globalKeys.add('browser');
47
+ }
48
+
49
+ const hasDevices = config.hasAndroidDevices || config.hasIOSDevices ||
50
+ config.hasDesktopDevices || (config.avgDevicePerUser && config.avgDevicePerUser > 0);
51
+ if (hasDevices) {
52
+ for (const k of DEVICE_KEYS) globalKeys.add(k);
53
+ }
54
+
55
+ if (config.hasCampaigns) {
56
+ for (const k of CAMPAIGN_KEYS) globalKeys.add(k);
57
+ }
58
+
59
+ if (config.personas) {
60
+ globalKeys.add('_persona');
61
+ }
62
+
63
+ if (config.dataQuality) {
64
+ globalKeys.add('_drop');
65
+ }
66
+
67
+ const events = config.events || [];
68
+ for (const ev of events) {
69
+ const keys = new Set();
70
+ if (ev.properties) {
71
+ for (const k of Object.keys(ev.properties)) {
72
+ keys.add(k);
73
+ }
74
+ }
75
+ perType.set(ev.event, keys);
76
+ }
77
+
78
+ // Group keys — per event type or global
79
+ if (config.groupKeys && Array.isArray(config.groupKeys)) {
80
+ for (const groupPair of config.groupKeys) {
81
+ const groupKey = groupPair[0];
82
+ const groupEvents = groupPair[2] || [];
83
+ if (!groupEvents.length) {
84
+ globalKeys.add(groupKey);
85
+ } else {
86
+ for (const eventName of groupEvents) {
87
+ ensurePerType(perType, eventName).add(groupKey);
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ // Funnel props — applied to events in funnel sequences
94
+ if (config.funnels && Array.isArray(config.funnels)) {
95
+ for (const funnel of config.funnels) {
96
+ if (funnel.props && Object.keys(funnel.props).length) {
97
+ const funnelPropKeys = Object.keys(funnel.props);
98
+ for (const eventName of (funnel.sequence || [])) {
99
+ if (eventName === '$experiment_started') continue;
100
+ for (const k of funnelPropKeys) {
101
+ ensurePerType(perType, eventName).add(k);
102
+ }
103
+ }
104
+ }
105
+ // $experiment_started event from experiment funnels
106
+ if (funnel.experiment) {
107
+ const expKeys = ensurePerType(perType, '$experiment_started');
108
+ expKeys.add('Experiment name');
109
+ expKeys.add('Variant name');
110
+ }
111
+ }
112
+ }
113
+
114
+ // World event injected props
115
+ if (config.worldEvents && Array.isArray(config.worldEvents)) {
116
+ for (const we of config.worldEvents) {
117
+ if (we.injectProps) {
118
+ const injectedKeys = Object.keys(we.injectProps);
119
+ const affects = we.affectsEvents;
120
+ if (affects === '*') {
121
+ for (const k of injectedKeys) globalKeys.add(k);
122
+ } else if (Array.isArray(affects)) {
123
+ for (const eventName of affects) {
124
+ for (const k of injectedKeys) {
125
+ ensurePerType(perType, eventName).add(k);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Build final schema: for each event type, merge global + per-type
134
+ const schema = new Map();
135
+ for (const [eventName, typeKeys] of perType) {
136
+ const merged = new Set(globalKeys);
137
+ for (const k of typeKeys) merged.add(k);
138
+ schema.set(eventName, merged);
139
+ }
140
+
141
+ // Event types that only appear in funnels but not in events[] config
142
+ // (e.g. $experiment_started) should still be in the schema
143
+ for (const [eventName, typeKeys] of perType) {
144
+ if (!schema.has(eventName)) {
145
+ const merged = new Set(globalKeys);
146
+ for (const k of typeKeys) merged.add(k);
147
+ schema.set(eventName, merged);
148
+ }
149
+ }
150
+
151
+ return schema;
152
+ }
153
+
154
+ /**
155
+ * @typedef {Object} EventTypeReport
156
+ * @property {string[]} expected
157
+ * @property {string[]} actual
158
+ * @property {string[]} added
159
+ * @property {string[]} missing
160
+ * @property {Object<string, {count: number, total: number, pct: number}>} coverage
161
+ * @property {'PASS'|'FAIL'} verdict
162
+ */
163
+
164
+ /**
165
+ * @typedef {Object} SchemaReport
166
+ * @property {boolean} pass
167
+ * @property {Object<string, EventTypeReport>} eventTypes
168
+ * @property {{pass: number, fail: number}} summary
169
+ * @property {Array<{eventType: string, column: string, coverage: number}>} flagStamping
170
+ */
171
+
172
+ /**
173
+ * Validate generated events against the config-derived schema.
174
+ * @param {Object[]} events — flat event objects from dungeon output
175
+ * @param {Dungeon} config — the dungeon config (pre- or post-validation)
176
+ * @returns {SchemaReport}
177
+ */
178
+ export function validateSchema(events, config) {
179
+ const expectedSchema = deriveExpectedSchema(config);
180
+
181
+ // Group events by type
182
+ /** @type {Map<string, Object[]>} */
183
+ const byType = new Map();
184
+ for (const ev of events) {
185
+ const name = ev.event;
186
+ if (!name) continue;
187
+ if (!byType.has(name)) byType.set(name, []);
188
+ byType.get(name).push(ev);
189
+ }
190
+
191
+ /** @type {Object<string, EventTypeReport>} */
192
+ const eventTypes = {};
193
+ /** @type {Array<{eventType: string, column: string, coverage: number}>} */
194
+ const flagStamping = [];
195
+ let passCount = 0;
196
+ let failCount = 0;
197
+
198
+ for (const [eventName, eventsOfType] of byType) {
199
+ const expected = expectedSchema.get(eventName) || new Set(CORE_KEYS);
200
+
201
+ // Collect all actual keys across events of this type
202
+ const actualKeys = new Set();
203
+ for (const ev of eventsOfType) {
204
+ for (const k of Object.keys(ev)) {
205
+ actualKeys.add(k);
206
+ }
207
+ }
208
+
209
+ const expectedArr = Array.from(expected).sort();
210
+ const actualArr = Array.from(actualKeys).sort();
211
+ const added = actualArr.filter(k => !expected.has(k));
212
+ const missing = expectedArr.filter(k => !actualKeys.has(k));
213
+
214
+ // Check coverage for added columns
215
+ /** @type {Object<string, {count: number, total: number, pct: number}>} */
216
+ const coverage = {};
217
+ let hasFailure = false;
218
+ const total = eventsOfType.length;
219
+
220
+ for (const col of added) {
221
+ let count = 0;
222
+ for (const ev of eventsOfType) {
223
+ if (ev[col] !== undefined) count++;
224
+ }
225
+ const pct = Math.round((count / total) * 10000) / 100;
226
+ coverage[col] = { count, total, pct };
227
+ if (pct < 100) {
228
+ hasFailure = true;
229
+ flagStamping.push({ eventType: eventName, column: col, coverage: pct });
230
+ }
231
+ }
232
+
233
+ const verdict = hasFailure ? 'FAIL' : 'PASS';
234
+ if (verdict === 'PASS') passCount++;
235
+ else failCount++;
236
+
237
+ eventTypes[eventName] = {
238
+ expected: expectedArr,
239
+ actual: actualArr,
240
+ added,
241
+ missing,
242
+ coverage,
243
+ verdict,
244
+ };
245
+ }
246
+
247
+ return {
248
+ pass: failCount === 0,
249
+ eventTypes,
250
+ summary: { pass: passCount, fail: failCount },
251
+ flagStamping,
252
+ };
253
+ }
254
+
255
+ function ensurePerType(perType, eventName) {
256
+ if (!perType.has(eventName)) perType.set(eventName, new Set());
257
+ return perType.get(eventName);
258
+ }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import DUNGEON_MASTER from '../../index.js';
23
23
  import { emulateBreakdown } from './emulate-breakdown.js';
24
+ import { validateSchema } from './schema-validator.js';
24
25
 
25
26
  /**
26
27
  * @typedef {Object} VerifyCheck
@@ -32,7 +33,7 @@ import { emulateBreakdown } from './emulate-breakdown.js';
32
33
  /**
33
34
  * @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
34
35
  * @param {VerifyCheck[]} checks
35
- * @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }> }>}
36
+ * @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }>, schemaReport: Object }>}
36
37
  */
37
38
  export async function verifyDungeon(config, checks) {
38
39
  if (!checks || !checks.length) throw new Error('verifyDungeon: at least one check required');
@@ -40,12 +41,12 @@ export async function verifyDungeon(config, checks) {
40
41
  if (Array.isArray(result)) result = result[0];
41
42
  const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData);
42
43
  const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData);
43
- const ctx = { events, profiles };
44
+ const schemaReport = validateSchema(events, config);
45
+ const ctx = { events, profiles, schemaReport };
44
46
  const results = [];
45
47
  for (const check of checks) {
46
48
  try {
47
49
  const breakdownArgs = { ...check.breakdown };
48
- // timeToConvert + attributedBy may want profiles; auto-inject if not provided.
49
50
  if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
50
51
  breakdownArgs.profiles = profiles;
51
52
  }
@@ -56,6 +57,6 @@ export async function verifyDungeon(config, checks) {
56
57
  results.push({ name: check.name, pass: false, detail: `error: ${err.message}` });
57
58
  }
58
59
  }
59
- const pass = results.every(r => r.pass);
60
- return { pass, results };
60
+ const pass = results.every(r => r.pass) && schemaReport.pass;
61
+ return { pass, results, schemaReport };
61
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ak--47/dungeon-master",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "generate fancy datasets",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -29,7 +29,7 @@ if (!dungeonPath) {
29
29
  process.exit(1);
30
30
  }
31
31
 
32
- const runName = positional[1] || 'verify-hooks';
32
+ const runName = positional[1] || 'verify-dungeon';
33
33
  const isSmall = flags.has('--small');
34
34
  const absolutePath = path.isAbsolute(dungeonPath)
35
35
  ? dungeonPath
package/types.d.ts CHANGED
@@ -1570,5 +1570,33 @@ export interface EmulateOptions {
1570
1570
 
1571
1571
  declare module '@ak--47/dungeon-master/verify' {
1572
1572
  export function emulateBreakdown(events: EventSchema[], config: EmulateOptions): Array<Record<string, unknown>>;
1573
- export function verifyDungeon(config: Dungeon, checks: Array<{ name: string; breakdown: EmulateOptions; assert: (rows: Array<Record<string, unknown>>, ctx: { events: EventSchema[]; profiles: UserProfile[] }) => { pass: boolean; detail?: string } }>): Promise<{ pass: boolean; results: Array<{ name: string; pass: boolean; detail?: string; rows?: Array<Record<string, unknown>> }> }>;
1573
+ export function verifyDungeon(config: Dungeon, checks: Array<{ name: string; breakdown: EmulateOptions; assert: (rows: Array<Record<string, unknown>>, ctx: { events: EventSchema[]; profiles: UserProfile[] }) => { pass: boolean; detail?: string } }>): Promise<{ pass: boolean; results: Array<{ name: string; pass: boolean; detail?: string; rows?: Array<Record<string, unknown>> }>; schemaReport: SchemaReport }>;
1574
+ export function deriveExpectedSchema(config: Dungeon): Map<string, Set<string>>;
1575
+ export function validateSchema(events: EventSchema[], config: Dungeon): SchemaReport;
1576
+
1577
+ interface SchemaReport {
1578
+ pass: boolean;
1579
+ eventTypes: Record<string, {
1580
+ expected: string[];
1581
+ actual: string[];
1582
+ added: string[];
1583
+ missing: string[];
1584
+ coverage: Record<string, { count: number; total: number; pct: number }>;
1585
+ verdict: 'PASS' | 'FAIL';
1586
+ }>;
1587
+ summary: { pass: number; fail: number };
1588
+ flagStamping: Array<{ eventType: string; column: string; coverage: number }>;
1589
+ }
1590
+ }
1591
+
1592
+ declare module '@ak--47/dungeon-master/utils' {
1593
+ export function dateRange(start?: string | number, end?: string | number, format?: string | null): () => string;
1594
+ export function listOf<T>(pool: T[], options?: { min?: number; max?: number }): () => T[];
1595
+ export function objectList(template: Record<string, ValueValid>, options?: { min?: number; max?: number }): () => Array<Record<string, unknown>>;
1596
+ export function weighNumRange(min: number, max: number, skew?: number, size?: number): number[];
1597
+ export function pickAWinner(items: string[], mostChosenIndex?: number): () => string[];
1598
+ export function initChance(seed?: string): unknown;
1599
+ export function TimeSoup(earliestTime: number, latestTime: number, peaks?: number, deviation?: number, mean?: number, dayOfWeekWeights?: number[] | null, hourOfDayWeights?: number[] | null): number;
1600
+ export function weighArray<T>(items: T[]): T[];
1601
+ export function generateUser(user_id: string, opts: Record<string, unknown>): Record<string, unknown>;
1574
1602
  }