@ontrails/schema 1.0.0-beta.0

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.
package/src/diff.ts ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Semantic diffing of surface maps.
3
+ */
4
+
5
+ import type {
6
+ DiffEntry,
7
+ DiffResult,
8
+ JsonSchema,
9
+ SurfaceMap,
10
+ SurfaceMapEntry,
11
+ } from './types.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ type Severity = DiffEntry['severity'];
18
+
19
+ interface DetailAccumulator {
20
+ readonly details: string[];
21
+ severity: Severity;
22
+ }
23
+
24
+ const escalate = (acc: DetailAccumulator, severity: Severity): void => {
25
+ const rank: Record<Severity, number> = { breaking: 2, info: 0, warning: 1 };
26
+ if (rank[severity] > rank[acc.severity]) {
27
+ acc.severity = severity;
28
+ }
29
+ };
30
+
31
+ const addDetail = (
32
+ acc: DetailAccumulator,
33
+ severity: Severity,
34
+ message: string
35
+ ): void => {
36
+ acc.details.push(message);
37
+ escalate(acc, severity);
38
+ };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Utility
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const capitalize = (s: string): string =>
45
+ `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Schema field diffing
49
+ // ---------------------------------------------------------------------------
50
+
51
+ interface SchemaProperties {
52
+ readonly properties?: Readonly<Record<string, JsonSchema>>;
53
+ readonly required?: readonly string[];
54
+ }
55
+
56
+ const getProperties = (
57
+ schema: JsonSchema | undefined
58
+ ): Record<string, JsonSchema> => {
59
+ if (!schema) {
60
+ return {};
61
+ }
62
+ const props = (schema as SchemaProperties).properties;
63
+ return props ? { ...props } : {};
64
+ };
65
+
66
+ const getRequired = (schema: JsonSchema | undefined): ReadonlySet<string> => {
67
+ if (!schema) {
68
+ return new Set();
69
+ }
70
+ const req = (schema as SchemaProperties).required;
71
+ return new Set(req);
72
+ };
73
+
74
+ const inferSchemaType = (schema: JsonSchema): string => {
75
+ if (typeof schema['type'] === 'string') {
76
+ return schema['type'];
77
+ }
78
+ if (schema['const'] !== undefined) {
79
+ return `const(${String(schema['const'])})`;
80
+ }
81
+ if (schema['anyOf']) {
82
+ return 'union';
83
+ }
84
+ if (schema['enum']) {
85
+ return 'enum';
86
+ }
87
+ return 'unknown';
88
+ };
89
+
90
+ const getType = (schema: JsonSchema | undefined): string =>
91
+ schema ? inferSchemaType(schema) : 'unknown';
92
+
93
+ /** Diff a single field that was added. */
94
+ const diffAddedField = (
95
+ acc: DetailAccumulator,
96
+ direction: 'input' | 'output',
97
+ key: string,
98
+ currRequired: ReadonlySet<string>
99
+ ): void => {
100
+ if (direction === 'input' && currRequired.has(key)) {
101
+ addDetail(acc, 'breaking', `Required ${direction} field "${key}" added`);
102
+ } else {
103
+ const label = direction === 'input' ? `Optional ${direction}` : 'Output';
104
+ addDetail(acc, 'info', `${label} field "${key}" added`);
105
+ }
106
+ };
107
+
108
+ /** Diff a single field present in both prev and curr. */
109
+ const diffModifiedField = (
110
+ acc: DetailAccumulator,
111
+ direction: 'input' | 'output',
112
+ key: string,
113
+ prevProps: Record<string, JsonSchema>,
114
+ currProps: Record<string, JsonSchema>,
115
+ prevRequired: ReadonlySet<string>,
116
+ currRequired: ReadonlySet<string>
117
+ ): void => {
118
+ const prevType = getType(prevProps[key]);
119
+ const currType = getType(currProps[key]);
120
+ if (prevType !== currType) {
121
+ addDetail(
122
+ acc,
123
+ 'breaking',
124
+ `${capitalize(direction)} field "${key}" type changed: ${prevType} -> ${currType}`
125
+ );
126
+ }
127
+ if (
128
+ direction === 'input' &&
129
+ !prevRequired.has(key) &&
130
+ currRequired.has(key)
131
+ ) {
132
+ addDetail(
133
+ acc,
134
+ 'breaking',
135
+ `Input field "${key}" changed from optional to required`
136
+ );
137
+ }
138
+ };
139
+
140
+ /** Diff a single key across prev/curr schemas. */
141
+ const diffKey = (
142
+ acc: DetailAccumulator,
143
+ direction: 'input' | 'output',
144
+ key: string,
145
+ prevProps: Record<string, JsonSchema>,
146
+ currProps: Record<string, JsonSchema>,
147
+ prevRequired: ReadonlySet<string>,
148
+ currRequired: ReadonlySet<string>
149
+ ): void => {
150
+ const inPrev = key in prevProps;
151
+ const inCurr = key in currProps;
152
+ if (!inPrev && inCurr) {
153
+ diffAddedField(acc, direction, key, currRequired);
154
+ } else if (inPrev && !inCurr) {
155
+ addDetail(
156
+ acc,
157
+ 'breaking',
158
+ `${capitalize(direction)} field "${key}" removed`
159
+ );
160
+ } else if (inPrev && inCurr) {
161
+ diffModifiedField(
162
+ acc,
163
+ direction,
164
+ key,
165
+ prevProps,
166
+ currProps,
167
+ prevRequired,
168
+ currRequired
169
+ );
170
+ }
171
+ };
172
+
173
+ const diffSchemaFields = (
174
+ acc: DetailAccumulator,
175
+ direction: 'input' | 'output',
176
+ prev: JsonSchema | undefined,
177
+ curr: JsonSchema | undefined
178
+ ): void => {
179
+ const prevProps = getProperties(prev);
180
+ const currProps = getProperties(curr);
181
+ const prevRequired = getRequired(prev);
182
+ const currRequired = getRequired(curr);
183
+ const allKeys = new Set([
184
+ ...Object.keys(prevProps),
185
+ ...Object.keys(currProps),
186
+ ]);
187
+
188
+ for (const key of [...allKeys].toSorted()) {
189
+ diffKey(
190
+ acc,
191
+ direction,
192
+ key,
193
+ prevProps,
194
+ currProps,
195
+ prevRequired,
196
+ currRequired
197
+ );
198
+ }
199
+ };
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Per-entry diffing
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** Diff surface additions and removals. */
206
+ const diffSurfaces = (
207
+ acc: DetailAccumulator,
208
+ prev: SurfaceMapEntry,
209
+ curr: SurfaceMapEntry
210
+ ): void => {
211
+ const prevSurfaces = new Set(prev.surfaces);
212
+ const currSurfaces = new Set(curr.surfaces);
213
+ for (const s of [...currSurfaces].toSorted()) {
214
+ if (!prevSurfaces.has(s)) {
215
+ addDetail(acc, 'info', `Surface "${s}" added`);
216
+ }
217
+ }
218
+ for (const s of [...prevSurfaces].toSorted()) {
219
+ if (!currSurfaces.has(s)) {
220
+ addDetail(acc, 'breaking', `Surface "${s}" removed`);
221
+ }
222
+ }
223
+ };
224
+
225
+ /** Diff safety markers, description, and deprecation. */
226
+ const diffMetadata = (
227
+ acc: DetailAccumulator,
228
+ prev: SurfaceMapEntry,
229
+ curr: SurfaceMapEntry
230
+ ): void => {
231
+ for (const marker of ['readOnly', 'destructive', 'idempotent'] as const) {
232
+ if (prev[marker] !== curr[marker]) {
233
+ addDetail(
234
+ acc,
235
+ 'warning',
236
+ `${marker} changed: ${String(prev[marker] ?? false)} -> ${String(curr[marker] ?? false)}`
237
+ );
238
+ }
239
+ }
240
+ if (prev.description !== curr.description) {
241
+ addDetail(acc, 'info', 'Description updated');
242
+ }
243
+ if (!prev.deprecated && curr.deprecated) {
244
+ const msg = curr.replacedBy
245
+ ? `Deprecated (replaced by ${curr.replacedBy})`
246
+ : 'Deprecated';
247
+ addDetail(acc, 'warning', msg);
248
+ } else if (prev.deprecated && !curr.deprecated) {
249
+ addDetail(acc, 'info', 'Undeprecated');
250
+ }
251
+ };
252
+
253
+ /** Build a follows-changed description from added/removed arrays. */
254
+ const buildFollowsMessage = (added: string[], removed: string[]): string => {
255
+ const parts: string[] = [];
256
+ if (added.length > 0) {
257
+ parts.push(`added "${added.join('", "')}"`);
258
+ }
259
+ if (removed.length > 0) {
260
+ parts.push(`removed "${removed.join('", "')}"`);
261
+ }
262
+ return `Follows changed: ${parts.join(', ')}`;
263
+ };
264
+
265
+ /** Diff follows arrays. */
266
+ const diffFollows = (
267
+ acc: DetailAccumulator,
268
+ prev: SurfaceMapEntry,
269
+ curr: SurfaceMapEntry
270
+ ): void => {
271
+ const prevFollows = new Set(prev.follows);
272
+ const currFollows = new Set(curr.follows);
273
+ const added = [...currFollows].filter((f) => !prevFollows.has(f)).toSorted();
274
+ const removed = [...prevFollows]
275
+ .filter((f) => !currFollows.has(f))
276
+ .toSorted();
277
+ if (added.length > 0 || removed.length > 0) {
278
+ addDetail(acc, 'warning', buildFollowsMessage(added, removed));
279
+ }
280
+ };
281
+
282
+ const diffEntry = (
283
+ prev: SurfaceMapEntry,
284
+ curr: SurfaceMapEntry
285
+ ): DiffEntry | undefined => {
286
+ const acc: DetailAccumulator = { details: [], severity: 'info' };
287
+
288
+ diffSchemaFields(acc, 'input', prev.input, curr.input);
289
+ diffSchemaFields(acc, 'output', prev.output, curr.output);
290
+ diffSurfaces(acc, prev, curr);
291
+ diffMetadata(acc, prev, curr);
292
+ diffFollows(acc, prev, curr);
293
+
294
+ if (acc.details.length === 0) {
295
+ return undefined;
296
+ }
297
+
298
+ return {
299
+ change: 'modified',
300
+ details: acc.details,
301
+ id: curr.id,
302
+ kind: curr.kind,
303
+ severity: acc.severity,
304
+ };
305
+ };
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Public API
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * Compute a semantic diff between two surface maps.
313
+ *
314
+ * Classifies each change with a severity:
315
+ * - `info`: new trail, optional field added, output field added, description change
316
+ * - `warning`: safety marker change, deprecation, follows change
317
+ * - `breaking`: trail removed, required input added, field removed, type change, surface removed
318
+ */
319
+ /** Find entries added in curr that don't exist in prev. */
320
+ const findAdded = (
321
+ prevById: Map<string, SurfaceMapEntry>,
322
+ currById: Map<string, SurfaceMapEntry>
323
+ ): DiffEntry[] =>
324
+ [...currById.entries()]
325
+ .filter(([id]) => !prevById.has(id))
326
+ .map(([id, entry]) => ({
327
+ change: 'added' as const,
328
+ details: [`Trail "${id}" added`],
329
+ id,
330
+ kind: entry.kind,
331
+ severity: 'info' as const,
332
+ }));
333
+
334
+ /** Find entries removed from prev that don't exist in curr. */
335
+ const findRemoved = (
336
+ prevById: Map<string, SurfaceMapEntry>,
337
+ currById: Map<string, SurfaceMapEntry>
338
+ ): DiffEntry[] =>
339
+ [...prevById.entries()]
340
+ .filter(([id]) => !currById.has(id))
341
+ .map(([id, entry]) => ({
342
+ change: 'removed' as const,
343
+ details: [`Trail "${id}" removed`],
344
+ id,
345
+ kind: entry.kind,
346
+ severity: 'breaking' as const,
347
+ }));
348
+
349
+ /** Find entries modified between prev and curr. */
350
+ const findModified = (
351
+ prevById: Map<string, SurfaceMapEntry>,
352
+ currById: Map<string, SurfaceMapEntry>
353
+ ): DiffEntry[] => {
354
+ const results: DiffEntry[] = [];
355
+ for (const [id, currEntry] of currById) {
356
+ const prevEntry = prevById.get(id);
357
+ if (prevEntry) {
358
+ const diff = diffEntry(prevEntry, currEntry);
359
+ if (diff) {
360
+ results.push(diff);
361
+ }
362
+ }
363
+ }
364
+ return results;
365
+ };
366
+
367
+ /** Collect all diff entries (added, removed, modified) between two maps. */
368
+ const collectDiffEntries = (
369
+ prevById: Map<string, SurfaceMapEntry>,
370
+ currById: Map<string, SurfaceMapEntry>
371
+ ): DiffEntry[] => [
372
+ ...findAdded(prevById, currById),
373
+ ...findRemoved(prevById, currById),
374
+ ...findModified(prevById, currById),
375
+ ];
376
+
377
+ export const diffSurfaceMaps = (
378
+ prev: SurfaceMap,
379
+ curr: SurfaceMap
380
+ ): DiffResult => {
381
+ const prevById = new Map(prev.entries.map((e) => [e.id, e]));
382
+ const currById = new Map(curr.entries.map((e) => [e.id, e]));
383
+ const sorted = collectDiffEntries(prevById, currById).toSorted((a, b) =>
384
+ a.id.localeCompare(b.id)
385
+ );
386
+
387
+ const breaking = sorted.filter((e) => e.severity === 'breaking');
388
+ const warnings = sorted.filter((e) => e.severity === 'warning');
389
+ const info = sorted.filter((e) => e.severity === 'info');
390
+
391
+ return {
392
+ breaking,
393
+ entries: sorted,
394
+ hasBreaking: breaking.length > 0,
395
+ info,
396
+ warnings,
397
+ };
398
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Generate a deterministic surface map from a Topo.
3
+ */
4
+
5
+ import { zodToJsonSchema } from '@ontrails/core';
6
+ import type { Event, Hike, Topo, Trail } from '@ontrails/core';
7
+
8
+ import type { JsonSchema, SurfaceMap, SurfaceMapEntry } from './types.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Sort object keys lexicographically (shallow). */
15
+ const sortKeys = <T extends Record<string, unknown>>(obj: T): T => {
16
+ const sorted: Record<string, unknown> = {};
17
+ for (const key of Object.keys(obj).toSorted()) {
18
+ sorted[key] = obj[key];
19
+ }
20
+ return sorted as T;
21
+ };
22
+
23
+ /** Sort object keys recursively for deterministic JSON Schema output. */
24
+ const deepSortKeys = (value: unknown): unknown => {
25
+ if (Array.isArray(value)) {
26
+ return value.map(deepSortKeys);
27
+ }
28
+ if (value !== null && typeof value === 'object') {
29
+ const sorted: Record<string, unknown> = {};
30
+ for (const key of Object.keys(value).toSorted()) {
31
+ sorted[key] = deepSortKeys((value as Record<string, unknown>)[key]);
32
+ }
33
+ return sorted;
34
+ }
35
+ return value;
36
+ };
37
+
38
+ /** Convert a Zod schema to a deterministically-keyed JSON Schema. */
39
+ const toSortedJsonSchema = (schema: unknown): JsonSchema => {
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ const raw = zodToJsonSchema(schema as any);
42
+ return deepSortKeys(raw) as JsonSchema;
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Entry builders
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Extract surfaces from a raw object. */
50
+ const extractSurfaces = (raw: Record<string, unknown>): string[] =>
51
+ Array.isArray(raw['surfaces'])
52
+ ? (raw['surfaces'] as string[]).toSorted()
53
+ : [];
54
+
55
+ /** Add optional schemas to an entry. */
56
+ const addSchemas = (
57
+ entry: Record<string, unknown>,
58
+ t: Trail<unknown, unknown>
59
+ ): void => {
60
+ if (t.input) {
61
+ entry['input'] = toSortedJsonSchema(t.input);
62
+ }
63
+ if (t.output) {
64
+ entry['output'] = toSortedJsonSchema(t.output);
65
+ }
66
+ };
67
+
68
+ /** Add safety markers to an entry. */
69
+ const addSafetyMarkers = (
70
+ entry: Record<string, unknown>,
71
+ t: Trail<unknown, unknown>
72
+ ): void => {
73
+ if (t.readOnly === true) {
74
+ entry['readOnly'] = true;
75
+ }
76
+ if (t.destructive === true) {
77
+ entry['destructive'] = true;
78
+ }
79
+ if (t.idempotent === true) {
80
+ entry['idempotent'] = true;
81
+ }
82
+ };
83
+
84
+ /** Add deprecation and detours to an entry. */
85
+ const addExtendedMetadata = (
86
+ entry: Record<string, unknown>,
87
+ t: Trail<unknown, unknown>,
88
+ raw: Record<string, unknown>
89
+ ): void => {
90
+ if (raw['deprecated'] === true) {
91
+ entry['deprecated'] = true;
92
+ }
93
+ if (typeof raw['replacedBy'] === 'string') {
94
+ entry['replacedBy'] = raw['replacedBy'];
95
+ }
96
+ if (t.detours) {
97
+ const detoursSorted: Record<string, readonly string[]> = {};
98
+ for (const key of Object.keys(t.detours).toSorted()) {
99
+ detoursSorted[key] = (t.detours[key] ?? []).toSorted();
100
+ }
101
+ entry['detours'] = detoursSorted;
102
+ }
103
+ };
104
+
105
+ /** Add optional metadata fields to an entry. */
106
+ const addMetadata = (
107
+ entry: Record<string, unknown>,
108
+ t: Trail<unknown, unknown>,
109
+ raw: Record<string, unknown>
110
+ ): void => {
111
+ if (t.description !== undefined) {
112
+ entry['description'] = t.description;
113
+ }
114
+ addSafetyMarkers(entry, t);
115
+ addExtendedMetadata(entry, t, raw);
116
+ };
117
+
118
+ const trailToEntry = (t: Trail<unknown, unknown>): SurfaceMapEntry => {
119
+ const raw = t as unknown as Record<string, unknown>;
120
+ const entry: Record<string, unknown> = {
121
+ exampleCount: Array.isArray(t.examples) ? t.examples.length : 0,
122
+ id: t.id,
123
+ kind: t.kind,
124
+ surfaces: extractSurfaces(raw),
125
+ };
126
+
127
+ addSchemas(entry, t);
128
+ addMetadata(entry, t, raw);
129
+
130
+ return sortKeys(entry) as unknown as SurfaceMapEntry;
131
+ };
132
+
133
+ const hikeToEntry = (r: Hike<unknown, unknown>): SurfaceMapEntry => {
134
+ const base = trailToEntry(r as unknown as Trail<unknown, unknown>);
135
+ const raw = r as unknown as Record<string, unknown>;
136
+
137
+ const entry: Record<string, unknown> = {
138
+ ...base,
139
+ follows: r.follows.toSorted(),
140
+ kind: 'hike',
141
+ };
142
+
143
+ // Re-check surfaces on the route itself
144
+ if (Array.isArray(raw['surfaces'])) {
145
+ entry['surfaces'] = (raw['surfaces'] as string[]).toSorted();
146
+ }
147
+
148
+ return sortKeys(entry) as unknown as SurfaceMapEntry;
149
+ };
150
+
151
+ /** Add optional event-specific fields. */
152
+ const addEventFields = (
153
+ entry: Record<string, unknown>,
154
+ e: Event<unknown>,
155
+ raw: Record<string, unknown>
156
+ ): void => {
157
+ if (e.payload) {
158
+ entry['input'] = toSortedJsonSchema(e.payload);
159
+ }
160
+ if (e.description !== undefined) {
161
+ entry['description'] = e.description;
162
+ }
163
+ if (raw['deprecated'] === true) {
164
+ entry['deprecated'] = true;
165
+ }
166
+ if (typeof raw['replacedBy'] === 'string') {
167
+ entry['replacedBy'] = raw['replacedBy'];
168
+ }
169
+ };
170
+
171
+ const eventToEntry = (e: Event<unknown>): SurfaceMapEntry => {
172
+ const raw = e as unknown as Record<string, unknown>;
173
+ const entry: Record<string, unknown> = {
174
+ exampleCount: 0,
175
+ id: e.id,
176
+ kind: 'event',
177
+ surfaces: extractSurfaces(raw),
178
+ };
179
+ addEventFields(entry, e, raw);
180
+ return sortKeys(entry) as unknown as SurfaceMapEntry;
181
+ };
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Public API
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Generate a deterministic surface map from a Topo.
189
+ *
190
+ * Entries are sorted alphabetically by id. Object keys within each entry
191
+ * are sorted lexicographically for stable serialization.
192
+ */
193
+ export const generateSurfaceMap = (topo: Topo): SurfaceMap => {
194
+ const entries: SurfaceMapEntry[] = [];
195
+
196
+ // Collect all trails
197
+ for (const t of topo.trails.values()) {
198
+ entries.push(trailToEntry(t as Trail<unknown, unknown>));
199
+ }
200
+
201
+ // Collect all hikes
202
+ for (const r of topo.hikes.values()) {
203
+ entries.push(hikeToEntry(r as Hike<unknown, unknown>));
204
+ }
205
+
206
+ // Collect all events
207
+ for (const e of topo.events.values()) {
208
+ entries.push(eventToEntry(e as Event<unknown>));
209
+ }
210
+
211
+ // Sort alphabetically by id
212
+ const sorted = entries.toSorted((a, b) => a.id.localeCompare(b.id));
213
+
214
+ return {
215
+ entries: sorted,
216
+ generatedAt: new Date().toISOString(),
217
+ version: '1.0',
218
+ };
219
+ };
package/src/hash.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * SHA-256 hashing for surface maps.
3
+ *
4
+ * Uses Bun.CryptoHasher for native hashing.
5
+ */
6
+
7
+ import type { SurfaceMap } from './types.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Recursively sort all object keys for deterministic JSON serialization.
15
+ * Arrays preserve order; primitives pass through.
16
+ */
17
+ const canonicalize = (value: unknown): unknown => {
18
+ if (Array.isArray(value)) {
19
+ return value.map(canonicalize);
20
+ }
21
+ if (value !== null && typeof value === 'object') {
22
+ const sorted: Record<string, unknown> = {};
23
+ for (const key of Object.keys(value).toSorted()) {
24
+ sorted[key] = canonicalize((value as Record<string, unknown>)[key]);
25
+ }
26
+ return sorted;
27
+ }
28
+ return value;
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Compute a SHA-256 hash of a surface map.
37
+ *
38
+ * The `generatedAt` field is excluded so that identical topos always
39
+ * produce the same hash regardless of when they were generated.
40
+ */
41
+ export const hashSurfaceMap = (surfaceMap: SurfaceMap): string => {
42
+ // Strip generatedAt before hashing
43
+ const { generatedAt: _unused, ...rest } = surfaceMap;
44
+
45
+ const canonical = canonicalize(rest);
46
+ const json = JSON.stringify(canonical);
47
+
48
+ const hasher = new Bun.CryptoHasher('sha256');
49
+ hasher.update(json);
50
+ return hasher.digest('hex');
51
+ };
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Generation
2
+ export { generateSurfaceMap } from './generate.js';
3
+ export { hashSurfaceMap } from './hash.js';
4
+ export { diffSurfaceMaps } from './diff.js';
5
+
6
+ // File I/O
7
+ export {
8
+ writeSurfaceMap,
9
+ readSurfaceMap,
10
+ writeSurfaceLock,
11
+ readSurfaceLock,
12
+ } from './io.js';
13
+
14
+ // Types
15
+ export type {
16
+ SurfaceMap,
17
+ SurfaceMapEntry,
18
+ DiffEntry,
19
+ DiffResult,
20
+ JsonSchema,
21
+ WriteOptions,
22
+ ReadOptions,
23
+ } from './types.js';