@ontrails/topography 0.2.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/io.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * File I/O for topo graphs and lock files.
3
+ */
4
+
5
+ import { mkdir } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+
8
+ import type {
9
+ LockManifest,
10
+ ReadOptions,
11
+ TopoGraph,
12
+ TrailsLock,
13
+ WorkspaceTopoMetadata,
14
+ WorkspaceTrailIndex,
15
+ WriteOptions,
16
+ } from './types.js';
17
+ import {
18
+ LOCK_MANIFEST_SCHEMA_VERSION,
19
+ lockManifestSchema,
20
+ trailsLockSchema,
21
+ topoGraphSchema,
22
+ } from './types.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const DEFAULT_DIR = '.trails';
29
+ const LOCK_MANIFEST_FILE = 'trails.lock';
30
+ const TOPO_GRAPH_FILE = 'topo.lock';
31
+ const REGENERATE_LOCK_MESSAGE =
32
+ 'Unsupported trails.lock format; regenerate with `trails compile`.';
33
+ const REGENERATE_TOPO_GRAPH_MESSAGE =
34
+ 'Unsupported topo.lock format; regenerate with `trails compile`.';
35
+
36
+ export const isTopoArtifactRegenerationError = (
37
+ error: unknown
38
+ ): error is Error =>
39
+ error instanceof Error &&
40
+ (error.message.includes(REGENERATE_LOCK_MESSAGE) ||
41
+ error.message.includes(REGENERATE_TOPO_GRAPH_MESSAGE));
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const isNotFound = (err: unknown): boolean =>
48
+ typeof err === 'object' &&
49
+ err !== null &&
50
+ (err as NodeJS.ErrnoException).code === 'ENOENT';
51
+
52
+ const resolveDir = (options?: ReadOptions | WriteOptions): string =>
53
+ options?.dir ?? DEFAULT_DIR;
54
+
55
+ const ensureDir = async (dir: string): Promise<void> => {
56
+ await mkdir(dir, { recursive: true });
57
+ };
58
+
59
+ const readTextIfExists = async (filePath: string): Promise<string | null> => {
60
+ try {
61
+ return await Bun.file(filePath).text();
62
+ } catch (error: unknown) {
63
+ if (!isNotFound(error)) {
64
+ throw error;
65
+ }
66
+ return null;
67
+ }
68
+ };
69
+
70
+ const parseJson = (
71
+ content: string,
72
+ fileName: string,
73
+ regenerateMessage: string
74
+ ): unknown => {
75
+ try {
76
+ return JSON.parse(content) as unknown;
77
+ } catch {
78
+ throw new Error(`Invalid JSON in ${fileName}. ${regenerateMessage}`);
79
+ }
80
+ };
81
+
82
+ const parseLockManifest = (content: string): LockManifest => {
83
+ const parsed = parseJson(
84
+ content,
85
+ LOCK_MANIFEST_FILE,
86
+ REGENERATE_LOCK_MESSAGE
87
+ );
88
+ const result = lockManifestSchema.safeParse(parsed);
89
+ if (result.success) {
90
+ return result.data;
91
+ }
92
+ throw new Error(REGENERATE_LOCK_MESSAGE);
93
+ };
94
+
95
+ const parseTrailsLock = (content: string): TrailsLock => {
96
+ const parsed = parseJson(
97
+ content,
98
+ LOCK_MANIFEST_FILE,
99
+ REGENERATE_LOCK_MESSAGE
100
+ );
101
+ const result = trailsLockSchema.safeParse(parsed);
102
+ if (result.success) {
103
+ return result.data as TrailsLock;
104
+ }
105
+ throw new Error(REGENERATE_LOCK_MESSAGE);
106
+ };
107
+
108
+ const parseTopoGraph = (content: string): TopoGraph => {
109
+ const parsed = parseJson(
110
+ content,
111
+ TOPO_GRAPH_FILE,
112
+ REGENERATE_TOPO_GRAPH_MESSAGE
113
+ );
114
+ const result = topoGraphSchema.safeParse(parsed);
115
+ if (result.success) {
116
+ return result.data as TopoGraph;
117
+ }
118
+ throw new Error(REGENERATE_TOPO_GRAPH_MESSAGE);
119
+ };
120
+
121
+ const lockManifestFromTrailsLock = (lock: TrailsLock): LockManifest => ({
122
+ artifacts: [
123
+ {
124
+ path: TOPO_GRAPH_FILE,
125
+ role: 'topo',
126
+ sha256: lock.topoGraphHash,
127
+ },
128
+ ],
129
+ scope: lock.scope,
130
+ summary: lock.summary,
131
+ version: LOCK_MANIFEST_SCHEMA_VERSION,
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // TopoGraph
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Write a topo graph to `<dir>/topo.lock`.
140
+ *
141
+ * Creates the directory if it doesn't exist. Returns the file path.
142
+ */
143
+ export const writeTopoGraph = async (
144
+ topoGraph: TopoGraph,
145
+ options?: WriteOptions
146
+ ): Promise<string> => {
147
+ const dir = resolveDir(options);
148
+ await ensureDir(dir);
149
+ const filePath = join(dir, TOPO_GRAPH_FILE);
150
+ const json = `${JSON.stringify(topoGraph, null, 2)}\n`;
151
+ await Bun.write(filePath, json);
152
+ return filePath;
153
+ };
154
+
155
+ /**
156
+ * Read a topo graph from `<dir>/trails.lock` or legacy `<dir>/topo.lock`.
157
+ */
158
+ export const readTopoGraph = async (
159
+ options?: ReadOptions
160
+ ): Promise<TopoGraph | null> => {
161
+ const dir = resolveDir(options);
162
+ const lockContent = await readTextIfExists(join(dir, LOCK_MANIFEST_FILE));
163
+ if (lockContent !== null) {
164
+ try {
165
+ return parseTrailsLock(lockContent).topoGraph as TopoGraph;
166
+ } catch (error) {
167
+ try {
168
+ parseLockManifest(lockContent);
169
+ } catch {
170
+ throw error;
171
+ }
172
+ const legacyTopoContent = await readTextIfExists(
173
+ join(dir, TOPO_GRAPH_FILE)
174
+ );
175
+ if (legacyTopoContent === null) {
176
+ throw error;
177
+ }
178
+ return parseTopoGraph(legacyTopoContent);
179
+ }
180
+ }
181
+
182
+ const content = await readTextIfExists(join(dir, TOPO_GRAPH_FILE));
183
+ return content === null ? null : parseTopoGraph(content);
184
+ };
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Lock Manifest
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Write a lock v4 manifest to `<dir>/trails.lock`.
192
+ *
193
+ * Creates the directory if it doesn't exist. Returns the file path.
194
+ */
195
+ export const writeLockManifest = async (
196
+ lockManifest: LockManifest,
197
+ options?: WriteOptions
198
+ ): Promise<string> => {
199
+ const dir = resolveDir(options);
200
+ await ensureDir(dir);
201
+ const filePath = join(dir, LOCK_MANIFEST_FILE);
202
+ const parsed = lockManifestSchema.parse(lockManifest);
203
+ await Bun.write(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
204
+ return filePath;
205
+ };
206
+
207
+ /**
208
+ * Write a v4 Trails lock envelope to `<dir>/trails.lock`.
209
+ */
210
+ export const writeTrailsLock = async (
211
+ trailsLock: TrailsLock,
212
+ options?: WriteOptions
213
+ ): Promise<string> => {
214
+ const dir = resolveDir(options);
215
+ await ensureDir(dir);
216
+ const filePath = join(dir, LOCK_MANIFEST_FILE);
217
+ const parsed = trailsLockSchema.parse(trailsLock);
218
+ await Bun.write(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
219
+ return filePath;
220
+ };
221
+
222
+ /**
223
+ * Read a lock v4 manifest from `<dir>/trails.lock`.
224
+ *
225
+ * v5 root locks are derived back to the v4 manifest shape for compatibility.
226
+ */
227
+ export const readLockManifest = async (
228
+ options?: ReadOptions
229
+ ): Promise<LockManifest | null> => {
230
+ const dir = resolveDir(options);
231
+ const content = await readTextIfExists(join(dir, LOCK_MANIFEST_FILE));
232
+ if (content === null) {
233
+ return null;
234
+ }
235
+
236
+ try {
237
+ return lockManifestFromTrailsLock(parseTrailsLock(content));
238
+ } catch {
239
+ return parseLockManifest(content);
240
+ }
241
+ };
242
+
243
+ /**
244
+ * Read a v4 Trails lock envelope from `<dir>/trails.lock`.
245
+ */
246
+ export const readTrailsLock = async (
247
+ options?: ReadOptions
248
+ ): Promise<TrailsLock | null> => {
249
+ const dir = resolveDir(options);
250
+ const content = await readTextIfExists(join(dir, LOCK_MANIFEST_FILE));
251
+ return content === null ? null : parseTrailsLock(content);
252
+ };
253
+
254
+ /**
255
+ * Read workspace metadata from the current topo graph artifact.
256
+ */
257
+ export const readWorkspaceTopoMetadata = async (
258
+ options?: ReadOptions
259
+ ): Promise<WorkspaceTopoMetadata | null> => {
260
+ const topoGraph = await readTopoGraph(options);
261
+ if (topoGraph === null) {
262
+ return null;
263
+ }
264
+ return topoGraph.workspace ?? null;
265
+ };
266
+
267
+ /**
268
+ * Read the workspace trail-id index from the current topo graph artifact.
269
+ */
270
+ export const readWorkspaceTrailIndex = async (
271
+ options?: ReadOptions
272
+ ): Promise<WorkspaceTrailIndex | null> => {
273
+ const workspace = await readWorkspaceTopoMetadata(options);
274
+ return workspace?.trails ?? null;
275
+ };
276
+
277
+ /** @deprecated Use {@link readWorkspaceTrailIndex}. */
278
+ export const readWorkspaceLock = async (
279
+ options?: ReadOptions
280
+ ): Promise<WorkspaceTrailIndex | null> => readWorkspaceTrailIndex(options);
@@ -0,0 +1,131 @@
1
+ import {
2
+ filterSurfaceTrails,
3
+ isDraftId,
4
+ zodToJsonSchema,
5
+ } from '@ontrails/core';
6
+ import type { Topo, Trail } from '@ontrails/core';
7
+
8
+ import type {
9
+ JsonSchema,
10
+ TopoGraphLibraryCollision,
11
+ TopoGraphLibraryExclusion,
12
+ TopoGraphLibraryExport,
13
+ TopoGraphLibraryDerived,
14
+ } from './types.js';
15
+
16
+ const sortKeys = <T extends Record<string, unknown>>(obj: T): T => {
17
+ const sorted: Record<string, unknown> = {};
18
+ for (const key of Object.keys(obj).toSorted()) {
19
+ sorted[key] = obj[key];
20
+ }
21
+ return sorted as T;
22
+ };
23
+
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
+ const toSortedJsonSchema = (schema: unknown): JsonSchema => {
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ const raw = zodToJsonSchema(schema as any);
41
+ return deepSortKeys(raw) as JsonSchema;
42
+ };
43
+
44
+ const deriveLibraryExportName = (trailId: string): string => {
45
+ const words = trailId.split(/[.-]/u).filter((word) => word.length > 0);
46
+ return words
47
+ .map((word, index) =>
48
+ index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)
49
+ )
50
+ .join('');
51
+ };
52
+
53
+ const isInternalTrail = (trail: Trail<unknown, unknown, unknown>): boolean =>
54
+ trail.visibility === 'internal' || trail.meta?.['internal'] === true;
55
+
56
+ const collectLibraryExclusions = (
57
+ topo: Topo,
58
+ selectedIds: ReadonlySet<string>
59
+ ): readonly TopoGraphLibraryExclusion[] => {
60
+ const excluded: TopoGraphLibraryExclusion[] = [];
61
+
62
+ for (const trail of topo.trails.values()) {
63
+ if (selectedIds.has(trail.id)) {
64
+ continue;
65
+ }
66
+ if (isDraftId(trail.id)) {
67
+ excluded.push({ reason: 'draft', trailId: trail.id });
68
+ } else if (trail.activationSources.length > 0) {
69
+ excluded.push({ reason: 'activation', trailId: trail.id });
70
+ } else if (isInternalTrail(trail as Trail<unknown, unknown, unknown>)) {
71
+ excluded.push({ reason: 'internal', trailId: trail.id });
72
+ }
73
+ }
74
+
75
+ return excluded.toSorted((a, b) => a.trailId.localeCompare(b.trailId));
76
+ };
77
+
78
+ export const deriveTopoGraphLibrary = (topo: Topo): TopoGraphLibraryDerived => {
79
+ const selected = filterSurfaceTrails([...topo.trails.values()]).filter(
80
+ (trail) => !isDraftId(trail.id)
81
+ );
82
+ const selectedIds = new Set(selected.map((trail) => trail.id));
83
+ const namesToTrailIds = new Map<string, string[]>();
84
+ const collisionNames = new Set<string>();
85
+ const exports: TopoGraphLibraryExport[] = [];
86
+
87
+ for (const trail of selected.toSorted((a, b) => a.id.localeCompare(b.id))) {
88
+ const exportName = deriveLibraryExportName(trail.id);
89
+ const existing = namesToTrailIds.get(exportName);
90
+ if (existing !== undefined) {
91
+ existing.push(trail.id);
92
+ collisionNames.add(exportName);
93
+ continue;
94
+ }
95
+
96
+ namesToTrailIds.set(exportName, [trail.id]);
97
+ exports.push(
98
+ sortKeys({
99
+ ...(trail.description === undefined
100
+ ? {}
101
+ : { description: trail.description }),
102
+ exportName,
103
+ ...(trail.input === undefined
104
+ ? {}
105
+ : { input: toSortedJsonSchema(trail.input) }),
106
+ intent: trail.intent,
107
+ nameSource: 'derived' as const,
108
+ ...(trail.output === undefined
109
+ ? {}
110
+ : { output: toSortedJsonSchema(trail.output) }),
111
+ resources: trail.resources.map((resource) => resource.id).toSorted(),
112
+ trailId: trail.id,
113
+ ...(trail.version === undefined ? {} : { version: trail.version }),
114
+ })
115
+ );
116
+ }
117
+
118
+ const collisions: TopoGraphLibraryCollision[] = [...collisionNames]
119
+ .map((name) => ({
120
+ exportName: name,
121
+ trailIds: namesToTrailIds.get(name) ?? [],
122
+ }))
123
+ .toSorted((a, b) => a.exportName.localeCompare(b.exportName));
124
+
125
+ return sortKeys({
126
+ app: topo.name,
127
+ collisions,
128
+ excluded: collectLibraryExclusions(topo, selectedIds),
129
+ exports,
130
+ }) as unknown as TopoGraphLibraryDerived;
131
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Namespaced overlay collection for topo graphs.
3
+ *
4
+ * Both derivation pipelines — `deriveTopoGraph` and the store-side
5
+ * `buildTopoGraph` — consume overlay registrations through this single
6
+ * collection path so compiled locks and fresh derivations cannot diverge.
7
+ */
8
+
9
+ import { SURFACES_OVERLAY_NAMESPACE, ValidationError } from '@ontrails/core';
10
+ import type { Topo } from '@ontrails/core';
11
+
12
+ import type {
13
+ TopoGraphOverlayRegistration,
14
+ TopoGraphOverlays,
15
+ } from './types.js';
16
+
17
+ const OVERLAY_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*$/u;
18
+
19
+ const DERIVE_REMEDIATION =
20
+ "Fix the contribution's derive output and rerun `trails compile`.";
21
+
22
+ /** Sort object keys recursively for deterministic overlay serialization. */
23
+ const deepSortKeys = (value: unknown): unknown => {
24
+ if (Array.isArray(value)) {
25
+ return value.map(deepSortKeys);
26
+ }
27
+ if (value !== null && typeof value === 'object') {
28
+ const sorted: Record<string, unknown> = {};
29
+ for (const key of Object.keys(value).toSorted()) {
30
+ sorted[key] = deepSortKeys((value as Record<string, unknown>)[key]);
31
+ }
32
+ return sorted;
33
+ }
34
+ return value;
35
+ };
36
+
37
+ const summarizeIssuePath = (path: readonly PropertyKey[]): string =>
38
+ path.length === 0 ? '(root)' : path.map(String).join('.');
39
+
40
+ /**
41
+ * Canonicalize schema-parsed facts into JSON-plain, deterministically ordered
42
+ * data: a JSON round-trip strips non-JSON residue, then a deep key-sort makes
43
+ * serialization order stable.
44
+ */
45
+ const canonicalizeOverlayFacts = (
46
+ namespace: string,
47
+ parsed: unknown
48
+ ): unknown => {
49
+ let serialized: string | undefined;
50
+ try {
51
+ serialized = JSON.stringify(parsed);
52
+ } catch {
53
+ serialized = undefined;
54
+ }
55
+ if (serialized === undefined) {
56
+ throw new ValidationError(
57
+ `Overlay namespace "${namespace}" derived facts are not JSON-serializable. ${DERIVE_REMEDIATION}`
58
+ );
59
+ }
60
+ return deepSortKeys(JSON.parse(serialized));
61
+ };
62
+
63
+ const assertValidNamespace = (namespace: string): void => {
64
+ if (!OVERLAY_NAMESPACE_PATTERN.test(namespace)) {
65
+ throw new ValidationError(
66
+ `Overlay namespace "${namespace}" must be dotted kebab-case (matching ${OVERLAY_NAMESPACE_PATTERN.source}). Fix the contribution's namespace and rerun \`trails compile\`.`
67
+ );
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Enforce the reserved `surfaces` namespace: surfaces obey app-authored
73
+ * overlays only, so an adapter-derived registration (absent provenance is
74
+ * adapter-derived) can never own it.
75
+ */
76
+ const assertSurfacesProvenance = (
77
+ registration: TopoGraphOverlayRegistration
78
+ ): void => {
79
+ if (
80
+ registration.namespace === SURFACES_OVERLAY_NAMESPACE &&
81
+ registration.provenance !== 'app-authored'
82
+ ) {
83
+ throw new ValidationError(
84
+ `Adapter-derived overlays cannot own the "${SURFACES_OVERLAY_NAMESPACE}" namespace. Author bindings with \`surfaceOverlay()\` in the app module and rerun \`trails compile\`.`
85
+ );
86
+ }
87
+ };
88
+
89
+ const deriveOverlayFacts = (
90
+ topo: Topo,
91
+ registration: TopoGraphOverlayRegistration
92
+ ): unknown => {
93
+ const derived = registration.derive(topo);
94
+ const parsed = registration.schema.safeParse(derived);
95
+ if (!parsed.success) {
96
+ const issues = parsed.error.issues
97
+ .map((issue) => `${summarizeIssuePath(issue.path)}: ${issue.message}`)
98
+ .join('; ');
99
+ throw new ValidationError(
100
+ `Overlay namespace "${registration.namespace}" derived facts failed schema validation (${issues}). ${DERIVE_REMEDIATION}`
101
+ );
102
+ }
103
+ return canonicalizeOverlayFacts(registration.namespace, parsed.data);
104
+ };
105
+
106
+ /**
107
+ * Collect namespaced fact overlays from overlay registrations.
108
+ *
109
+ * Each registration owns one dotted-kebab namespace; its derived facts are
110
+ * validated against the registration's schema, canonicalized into JSON-plain
111
+ * deep-key-sorted data, and assembled with namespaces sorted
112
+ * lexicographically. Returns `undefined` when no registrations are supplied
113
+ * so graphs without overlays stay byte-identical to pre-overlays locks.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const overlays = collectTopoGraphOverlays(app, [
118
+ * {
119
+ * derive: (topo) => ({ trailCount: topo.trails.size }),
120
+ * namespace: 'cloudflare',
121
+ * schema: z.object({ trailCount: z.number() }),
122
+ * },
123
+ * ]);
124
+ * // => { cloudflare: { trailCount: 3 } }
125
+ * ```
126
+ */
127
+ export const collectTopoGraphOverlays = (
128
+ topo: Topo,
129
+ registrations: readonly TopoGraphOverlayRegistration[] | undefined
130
+ ): TopoGraphOverlays | undefined => {
131
+ if (registrations === undefined || registrations.length === 0) {
132
+ return undefined;
133
+ }
134
+
135
+ const collected = new Map<string, unknown>();
136
+ for (const registration of registrations) {
137
+ assertValidNamespace(registration.namespace);
138
+ assertSurfacesProvenance(registration);
139
+ if (collected.has(registration.namespace)) {
140
+ throw new ValidationError(
141
+ `Duplicate overlay namespace "${registration.namespace}". Each contribution owns one namespace; remove the duplicate registration and rerun \`trails compile\`.`
142
+ );
143
+ }
144
+ collected.set(
145
+ registration.namespace,
146
+ deriveOverlayFacts(topo, registration)
147
+ );
148
+ }
149
+
150
+ const overlays: Record<string, unknown> = {};
151
+ for (const namespace of [...collected.keys()].toSorted()) {
152
+ overlays[namespace] = collected.get(namespace);
153
+ }
154
+ return overlays;
155
+ };
package/src/permit.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { AnyTrail, PermitRequirement } from '@ontrails/core';
2
+
3
+ export type DerivedPermitRequirement =
4
+ | 'public'
5
+ | { readonly scopes: readonly string[] };
6
+
7
+ export const derivePermitRequirement = (
8
+ permit: PermitRequirement
9
+ ): DerivedPermitRequirement =>
10
+ permit === 'public' ? 'public' : { scopes: [...permit.scopes].toSorted() };
11
+
12
+ export const addPermitRequirement = (
13
+ entry: Record<string, unknown>,
14
+ trail: AnyTrail
15
+ ): void => {
16
+ if (trail.permit !== undefined) {
17
+ entry['permit'] = derivePermitRequirement(trail.permit);
18
+ }
19
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Content fingerprint over an app's source file set.
3
+ *
4
+ * The per-user topo store records this fingerprint on every snapshot so
5
+ * consumers that serve stored exports can detect that the app sources
6
+ * changed since the snapshot was taken and self-invalidate instead of
7
+ * silently serving pre-edit facts (TRL-1196).
8
+ */
9
+
10
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
11
+ import { join, relative } from 'node:path';
12
+
13
+ /** Directories that never contribute to the app source set. */
14
+ const EXCLUDED_DIRECTORIES = new Set([
15
+ '.git',
16
+ '.trails',
17
+ '.trails-tmp',
18
+ '.turbo',
19
+ 'dist',
20
+ 'node_modules',
21
+ ]);
22
+
23
+ /** Files that are derived outputs rather than sources. */
24
+ const EXCLUDED_FILES = new Set(['trails.lock', 'topo.lock', 'bun.lock']);
25
+
26
+ const SOURCE_EXTENSIONS = new Set([
27
+ '.cjs',
28
+ '.cts',
29
+ '.js',
30
+ '.json',
31
+ '.jsx',
32
+ '.mjs',
33
+ '.mts',
34
+ '.ts',
35
+ '.tsx',
36
+ ]);
37
+
38
+ const hasSourceExtension = (fileName: string): boolean => {
39
+ const dot = fileName.lastIndexOf('.');
40
+ return dot !== -1 && SOURCE_EXTENSIONS.has(fileName.slice(dot));
41
+ };
42
+
43
+ const collectSourceFiles = (dir: string, out: string[]) => {
44
+ let entries: string[];
45
+ try {
46
+ entries = readdirSync(dir);
47
+ } catch {
48
+ return;
49
+ }
50
+ for (const entry of entries.toSorted()) {
51
+ const entryPath = join(dir, entry);
52
+ let stats: ReturnType<typeof statSync>;
53
+ try {
54
+ stats = statSync(entryPath);
55
+ } catch {
56
+ continue;
57
+ }
58
+ if (stats.isDirectory()) {
59
+ if (!EXCLUDED_DIRECTORIES.has(entry)) {
60
+ collectSourceFiles(entryPath, out);
61
+ }
62
+ continue;
63
+ }
64
+ if (
65
+ stats.isFile() &&
66
+ hasSourceExtension(entry) &&
67
+ !EXCLUDED_FILES.has(entry)
68
+ ) {
69
+ out.push(entryPath);
70
+ }
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Derive a SHA-256 fingerprint of the source files under `rootDir`.
76
+ *
77
+ * The fingerprint covers relative paths and file contents, so edits,
78
+ * additions, removals, and renames all change it. Excluded directories
79
+ * (`node_modules`, `dist`, `.git`, `.trails`, `.trails-tmp`, `.turbo`)
80
+ * and derived artifacts (`trails.lock`, `topo.lock`, `bun.lock`) never
81
+ * participate, so recompiling does not invalidate its own fingerprint.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const fingerprint = deriveSourceFingerprint('/path/to/app');
86
+ * // "3f8a…" — stable until a source file changes
87
+ * ```
88
+ */
89
+ export const deriveSourceFingerprint = (rootDir: string): string => {
90
+ const files: string[] = [];
91
+ collectSourceFiles(rootDir, files);
92
+
93
+ const hasher = new Bun.CryptoHasher('sha256');
94
+ for (const filePath of files) {
95
+ const fileHasher = new Bun.CryptoHasher('sha256');
96
+ fileHasher.update(readFileSync(filePath));
97
+ hasher.update(relative(rootDir, filePath));
98
+ hasher.update('\0');
99
+ hasher.update(fileHasher.digest('hex'));
100
+ hasher.update('\n');
101
+ }
102
+ return hasher.digest('hex');
103
+ };