@ontrails/source 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/trails.ts ADDED
@@ -0,0 +1,826 @@
1
+ /** Shared Trails framework and generic entity-definition recognition helpers. */
2
+
3
+ import { isAstNode, isProperty } from './nodes.js';
4
+ import type { AstNode } from './nodes.js';
5
+ import {
6
+ extractBindingName,
7
+ extractStringLiteral,
8
+ extractStringOrTemplateLiteral,
9
+ findConfigProperty,
10
+ identifierName,
11
+ } from './literals.js';
12
+ import {
13
+ isMemberAccessNonComputed,
14
+ isShadowed,
15
+ walkWithScopes,
16
+ } from './scopes.js';
17
+ import { walk } from './walk.js';
18
+
19
+ export interface TrailDefinition {
20
+ /** Trail ID string, e.g. "entity.show" */
21
+ readonly id: string;
22
+ /** "trail" or "signal" */
23
+ readonly kind: string;
24
+ /** The config object argument (second arg to trail() call) */
25
+ readonly config: AstNode;
26
+ /** Start offset of the call expression */
27
+ readonly start: number;
28
+ }
29
+
30
+ /**
31
+ * Find all `trail("id", { ... })`, `trail({ id: "x", ... })`, and
32
+ * `signal("id", { ... })` call sites.
33
+ *
34
+ * Returns the trail ID, kind, and config object node for each definition.
35
+ */
36
+ const TRAIL_CALLEE_NAMES = new Set(['signal', 'trail']);
37
+
38
+ /**
39
+ * Source prefix for the Trails framework package whose namespace imports are
40
+ * recognized as carriers of `trail()` / `signal()` / `entity()` primitives.
41
+ *
42
+ * A namespaced callee like `core.trail(...)` is only treated as a framework
43
+ * call when the receiver identifier resolves to an `import * as core from
44
+ * '@ontrails/...'` in the same file. An unrelated `analytics.trail(...)`
45
+ * whose `analytics` comes from a different module (or no import at all)
46
+ * is ignored.
47
+ */
48
+ const FRAMEWORK_NAMESPACE_SOURCE_PREFIX = '@ontrails/';
49
+
50
+ export const isFrameworkNamespaceSource = (value: unknown): boolean =>
51
+ typeof value === 'string' &&
52
+ value.startsWith(FRAMEWORK_NAMESPACE_SOURCE_PREFIX);
53
+
54
+ /**
55
+ * Collect local binding names introduced by `import * as <name> from
56
+ * '@ontrails/...'` declarations. Used to gate namespaced framework-primitive
57
+ * calls so an unrelated `analytics.trail(...)` doesn't match.
58
+ */
59
+ export const getImportSourceValue = (node: AstNode): unknown => {
60
+ const sourceNode = (node as unknown as { source?: AstNode }).source;
61
+ return sourceNode
62
+ ? (sourceNode as unknown as { value?: unknown }).value
63
+ : undefined;
64
+ };
65
+
66
+ const addNamespaceImportBindings = (
67
+ node: AstNode,
68
+ names: Set<string>
69
+ ): void => {
70
+ const specifiers =
71
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
72
+ for (const spec of specifiers) {
73
+ if (spec.type !== 'ImportNamespaceSpecifier') {
74
+ continue;
75
+ }
76
+ const { local } = spec as unknown as { local?: AstNode };
77
+ const localName = identifierName(local);
78
+ if (localName) {
79
+ names.add(localName);
80
+ }
81
+ }
82
+ };
83
+
84
+ const TOP_LEVEL_NAMED_DECL_TYPES = new Set([
85
+ 'ClassDeclaration',
86
+ 'FunctionDeclaration',
87
+ 'TSEnumDeclaration',
88
+ 'TSModuleDeclaration',
89
+ ]);
90
+
91
+ const removeVarDeclarationShadowedNames = (
92
+ stmt: AstNode,
93
+ names: Set<string>
94
+ ): void => {
95
+ const declarations =
96
+ (stmt as unknown as { declarations?: readonly AstNode[] }).declarations ??
97
+ [];
98
+ for (const d of declarations) {
99
+ const { id } = d as unknown as { id?: AstNode };
100
+ const n = identifierName(id);
101
+ if (n) {
102
+ names.delete(n);
103
+ }
104
+ }
105
+ };
106
+
107
+ const removeNamedDeclShadowedName = (
108
+ stmt: AstNode,
109
+ names: Set<string>
110
+ ): void => {
111
+ const { id } = stmt as unknown as { id?: AstNode };
112
+ const n = identifierName(id);
113
+ if (n) {
114
+ names.delete(n);
115
+ }
116
+ };
117
+
118
+ const removeTopLevelShadowedNames = (
119
+ stmt: AstNode,
120
+ names: Set<string>
121
+ ): void => {
122
+ if (
123
+ stmt.type === 'ExportNamedDeclaration' ||
124
+ stmt.type === 'ExportDefaultDeclaration'
125
+ ) {
126
+ const { declaration } = stmt as unknown as { declaration?: AstNode };
127
+ if (declaration) {
128
+ removeTopLevelShadowedNames(declaration, names);
129
+ }
130
+ return;
131
+ }
132
+ if (stmt.type === 'VariableDeclaration') {
133
+ removeVarDeclarationShadowedNames(stmt, names);
134
+ return;
135
+ }
136
+ if (TOP_LEVEL_NAMED_DECL_TYPES.has(stmt.type)) {
137
+ removeNamedDeclShadowedName(stmt, names);
138
+ }
139
+ };
140
+
141
+ export const collectFrameworkNamespaceBindings = (
142
+ ast: AstNode
143
+ ): ReadonlySet<string> => {
144
+ const names = new Set<string>();
145
+ walk(ast, (node) => {
146
+ if (node.type !== 'ImportDeclaration') {
147
+ return;
148
+ }
149
+ if (!isFrameworkNamespaceSource(getImportSourceValue(node))) {
150
+ return;
151
+ }
152
+ addNamespaceImportBindings(node, names);
153
+ });
154
+ if (names.size === 0) {
155
+ return names;
156
+ }
157
+ // A same-named top-level declaration (class / enum / namespace / var /
158
+ // function / lexical binding) shadows the namespace import at module scope.
159
+ // The scope walker treats Program as the outermost frame and skips it when
160
+ // testing for inner shadows, so we have to strip these collisions here.
161
+ if (ast.type === 'Program') {
162
+ const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
163
+ for (const stmt of body) {
164
+ removeTopLevelShadowedNames(stmt, names);
165
+ }
166
+ }
167
+ return names;
168
+ };
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Scope-aware framework-namespace resolution
172
+ // ---------------------------------------------------------------------------
173
+ //
174
+ // A module-level `import * as core from '@ontrails/core'` makes `core` a
175
+ // framework-namespace binding, but a function-local `const core = {...}` (or
176
+ // param, `let`, `var`, `function`, class, catch param) shadows the import for
177
+ // the duration of that scope. A name-only check is not enough to trust
178
+ // `core.trail(...)` — we have to walk scopes outward from each call site and
179
+ // verify the first declaration of the receiver IS the namespace import.
180
+ //
181
+ // {@link collectFrameworkNamespacedCallStarts} performs that walk once per
182
+ // AST and returns the set of `CallExpression` start offsets whose receiver is
183
+ // provably the framework binding. Downstream helpers gate on this set instead
184
+ // of the bare names, so a local shadow cannot sneak through.
185
+
186
+ export const resolveNamespacedMemberNames = (
187
+ callee: AstNode
188
+ ): { readonly receiver: string; readonly property: string } | null => {
189
+ if (!isMemberAccessNonComputed(callee)) {
190
+ return null;
191
+ }
192
+ const { object } = callee as unknown as { object?: AstNode };
193
+ const receiver = identifierName(object);
194
+ if (!receiver) {
195
+ return null;
196
+ }
197
+ const prop = (callee as unknown as { property?: AstNode }).property;
198
+ const property =
199
+ prop?.type === 'Identifier'
200
+ ? ((prop as unknown as { name?: string }).name ?? null)
201
+ : null;
202
+ return property ? { property, receiver } : null;
203
+ };
204
+
205
+ const getFrameworkCallReceiver = (
206
+ node: AstNode,
207
+ frameworkNamespaces: ReadonlySet<string>
208
+ ): string | null => {
209
+ if (node.type !== 'CallExpression') {
210
+ return null;
211
+ }
212
+ const callee = node['callee'] as AstNode | undefined;
213
+ if (!callee) {
214
+ return null;
215
+ }
216
+ const names = resolveNamespacedMemberNames(callee);
217
+ if (!names || !frameworkNamespaces.has(names.receiver)) {
218
+ return null;
219
+ }
220
+ return names.receiver;
221
+ };
222
+
223
+ /**
224
+ * Walk the AST with a scope stack and collect `CallExpression` start offsets
225
+ * whose callee is `<receiver>.<property>` where `<receiver>` is proven to
226
+ * resolve to a framework namespace import (i.e. not shadowed by any
227
+ * enclosing scope). Used to gate namespaced `core.trail(...)` /
228
+ * `core.signal(...)` / `core.entity(...)` resolution against local shadows.
229
+ */
230
+ const collectFrameworkNamespacedCallStarts = (
231
+ ast: AstNode,
232
+ frameworkNamespaces: ReadonlySet<string>
233
+ ): ReadonlySet<number> => {
234
+ const starts = new Set<number>();
235
+ if (frameworkNamespaces.size === 0) {
236
+ return starts;
237
+ }
238
+
239
+ walkWithScopes(ast, (node, scopes) => {
240
+ const receiver = getFrameworkCallReceiver(node, frameworkNamespaces);
241
+ if (!receiver || isShadowed(receiver, scopes)) {
242
+ return;
243
+ }
244
+ starts.add(node.start);
245
+ });
246
+
247
+ return starts;
248
+ };
249
+
250
+ const matchTrailPrimitiveName = (
251
+ name: string | undefined | null
252
+ ): string | null => (name && TRAIL_CALLEE_NAMES.has(name) ? name : null);
253
+
254
+ const getBareTrailCalleeName = (callee: AstNode): string | null => {
255
+ if (callee.type !== 'Identifier') {
256
+ return null;
257
+ }
258
+ return matchTrailPrimitiveName((callee as unknown as { name?: string }).name);
259
+ };
260
+
261
+ /**
262
+ * Extract the `{ receiverName, propertyName }` of a non-computed member-call
263
+ * callee, or null for anything else. Computed access (`ns[trail]()`) is
264
+ * intentionally rejected: the bracketed expression may resolve to any runtime
265
+ * value, so we cannot prove the call targets a specific member.
266
+ */
267
+ const isNonComputedMemberAccess = (callee: AstNode): boolean => {
268
+ if (
269
+ callee.type !== 'MemberExpression' &&
270
+ callee.type !== 'StaticMemberExpression'
271
+ ) {
272
+ return false;
273
+ }
274
+ return (callee as unknown as { computed?: boolean }).computed !== true;
275
+ };
276
+
277
+ export const getNamespacedMemberNames = (
278
+ callee: AstNode
279
+ ): { readonly receiver: string; readonly property: string } | null => {
280
+ if (!isNonComputedMemberAccess(callee)) {
281
+ return null;
282
+ }
283
+ const { object } = callee as unknown as { object?: AstNode };
284
+ const receiver = identifierName(object);
285
+ if (!receiver) {
286
+ return null;
287
+ }
288
+ const prop = (callee as unknown as { property?: AstNode }).property;
289
+ const property =
290
+ prop?.type === 'Identifier'
291
+ ? ((prop as unknown as { name?: string }).name ?? null)
292
+ : null;
293
+ return property ? { property, receiver } : null;
294
+ };
295
+
296
+ /**
297
+ * Resolution context for namespaced framework-primitive calls. Bundles the
298
+ * bare namespace-binding set with an optional set of proven-safe
299
+ * `CallExpression` start offsets from a scope-aware pre-pass. When the set of
300
+ * safe starts is present, a namespaced call only resolves if its start is in
301
+ * that set — so a function-local shadow of the namespace import does not
302
+ * leak through. When absent (e.g. from test helpers), the name-only gate is
303
+ * used as a backward-compatible fallback.
304
+ */
305
+ export interface FrameworkNamespaceContext {
306
+ readonly namespaces: ReadonlySet<string>;
307
+ readonly safeCallStarts?: ReadonlySet<number>;
308
+ }
309
+
310
+ export const asNamespaceContext = (
311
+ input: ReadonlySet<string> | FrameworkNamespaceContext | undefined
312
+ ): FrameworkNamespaceContext | undefined => {
313
+ if (!input) {
314
+ return undefined;
315
+ }
316
+ return input instanceof Set
317
+ ? { namespaces: input }
318
+ : (input as FrameworkNamespaceContext);
319
+ };
320
+
321
+ export const isNamespacedCallAllowed = (
322
+ callStart: number,
323
+ receiver: string,
324
+ ctx: FrameworkNamespaceContext
325
+ ): boolean => {
326
+ if (!ctx.namespaces.has(receiver)) {
327
+ return false;
328
+ }
329
+ // When `safeCallStarts` is present, it is the authoritative gate — it was
330
+ // built by a scope-aware pre-pass and already excludes shadowed receivers.
331
+ // Without it, fall back to the bare name check (used by unit-test hooks).
332
+ return ctx.safeCallStarts ? ctx.safeCallStarts.has(callStart) : true;
333
+ };
334
+
335
+ /**
336
+ * Resolve a namespaced `ns.trail(...)` / `ns.signal(...)` callee to its
337
+ * primitive name. When a {@link FrameworkNamespaceContext} is provided, the
338
+ * receiver must be a framework namespace binding AND — when a
339
+ * `safeCallStarts` set is present — the call site must appear in that set,
340
+ * meaning the receiver is not shadowed by any enclosing scope.
341
+ *
342
+ * When `context` is `undefined`, this falls back to permissive matching
343
+ * (any `ns.trail(...)` shape resolves). Inline resolution paths that do
344
+ * not have the surrounding AST available (e.g. `composes: [core.trail(...)]`
345
+ * or `on: [core.signal(...)]`) rely on this fallback. Scope-aware call
346
+ * sites always pass a context, so this only affects inline contexts where
347
+ * a best-effort name match is the intended behavior.
348
+ */
349
+ const getNamespacedTrailCalleeName = (
350
+ callExpr: AstNode,
351
+ callee: AstNode,
352
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
353
+ ): string | null => {
354
+ const names = getNamespacedMemberNames(callee);
355
+ if (!names) {
356
+ return null;
357
+ }
358
+ const ctx = asNamespaceContext(context);
359
+ if (ctx && !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
360
+ return null;
361
+ }
362
+ return matchTrailPrimitiveName(names.property);
363
+ };
364
+
365
+ /**
366
+ * Resolve the callee name of a trail/signal call expression.
367
+ *
368
+ * Matches both bare `trail(...)` / `signal(...)` identifiers and namespaced
369
+ * member-expression callees like `core.trail(...)` or `ns.signal(...)`, where
370
+ * the namespace must come from an `@ontrails/*` import and, when the scope
371
+ * pre-pass is wired in, be unshadowed at the call site.
372
+ */
373
+ const getTrailCalleeName = (
374
+ node: AstNode,
375
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
376
+ ): string | null => {
377
+ if (node.type !== 'CallExpression') {
378
+ return null;
379
+ }
380
+ const callee = node['callee'] as AstNode | undefined;
381
+ if (!callee) {
382
+ return null;
383
+ }
384
+ return (
385
+ getBareTrailCalleeName(callee) ??
386
+ getNamespacedTrailCalleeName(node, callee, context)
387
+ );
388
+ };
389
+
390
+ /**
391
+ * Test hook: exposes {@link getTrailCalleeName} for unit tests.
392
+ *
393
+ * Kept unexported from the module's public surface (no re-export from
394
+ * `index.ts`) so internal refactors stay free.
395
+ */
396
+ export const __getTrailCalleeNameForTest = getTrailCalleeName;
397
+
398
+ /** Extract args from a trail() call, handling both two-arg and single-object forms. */
399
+ const extractTrailArgs = (
400
+ node: AstNode
401
+ ): { idArg: AstNode | null; configArg: AstNode } | null => {
402
+ const args = node['arguments'] as readonly AstNode[] | undefined;
403
+ if (!args || args.length === 0) {
404
+ return null;
405
+ }
406
+
407
+ const [firstArg, secondArg] = args;
408
+ if (!firstArg) {
409
+ return null;
410
+ }
411
+
412
+ // Two-arg form: trail('id', { ... })
413
+ if (secondArg && firstArg.type !== 'ObjectExpression') {
414
+ return { configArg: secondArg, idArg: firstArg };
415
+ }
416
+
417
+ // Single-object form: trail({ id: 'x', ... })
418
+ return firstArg.type === 'ObjectExpression'
419
+ ? { configArg: firstArg, idArg: null }
420
+ : null;
421
+ };
422
+
423
+ /** Extract the string value from an `id` property inside a config ObjectExpression. */
424
+ const extractIdFromConfig = (config: AstNode): string | null => {
425
+ const idProp = findConfigProperty(config, 'id');
426
+ if (!idProp || !idProp.value) {
427
+ return null;
428
+ }
429
+ return extractStringOrTemplateLiteral(idProp.value as AstNode);
430
+ };
431
+
432
+ const extractTrailId = (trailArgs: {
433
+ idArg: AstNode | null;
434
+ configArg: AstNode;
435
+ }): string | null => {
436
+ if (trailArgs.idArg) {
437
+ return extractStringOrTemplateLiteral(trailArgs.idArg);
438
+ }
439
+ return extractIdFromConfig(trailArgs.configArg);
440
+ };
441
+
442
+ export const extractTrailDefinition = (
443
+ node: AstNode,
444
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
445
+ ): TrailDefinition | null => {
446
+ const calleeName = getTrailCalleeName(node, context);
447
+ if (!calleeName) {
448
+ return null;
449
+ }
450
+
451
+ const trailArgs = extractTrailArgs(node);
452
+ if (!trailArgs) {
453
+ return null;
454
+ }
455
+
456
+ const trailId = extractTrailId(trailArgs);
457
+ if (!trailId) {
458
+ return null;
459
+ }
460
+
461
+ return {
462
+ config: trailArgs.configArg,
463
+ id: trailId,
464
+ kind: calleeName,
465
+ start: node.start,
466
+ };
467
+ };
468
+
469
+ export const buildFrameworkNamespaceContext = (
470
+ ast: AstNode
471
+ ): FrameworkNamespaceContext => {
472
+ const namespaces = collectFrameworkNamespaceBindings(ast);
473
+ return {
474
+ namespaces,
475
+ safeCallStarts: collectFrameworkNamespacedCallStarts(ast, namespaces),
476
+ };
477
+ };
478
+
479
+ export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
480
+ const definitions: TrailDefinition[] = [];
481
+ const context = buildFrameworkNamespaceContext(ast);
482
+
483
+ walk(ast, (node) => {
484
+ const def = extractTrailDefinition(node, context);
485
+ if (def) {
486
+ definitions.push(def);
487
+ }
488
+ });
489
+
490
+ return definitions;
491
+ };
492
+
493
+ export interface EntityDefinition {
494
+ /** Local binding name when the entity is assigned to a variable. */
495
+ readonly bindingName?: string;
496
+ /** Entity name string, e.g. "user". */
497
+ readonly name: string;
498
+ /** Original call expression for the entity declaration. */
499
+ readonly call: AstNode;
500
+ /** Options object argument passed to entity(), when present. */
501
+ readonly options: AstNode | null;
502
+ /** Shape object argument passed to entity(). */
503
+ readonly shape: AstNode;
504
+ /** Start offset of the call expression. */
505
+ readonly start: number;
506
+ }
507
+
508
+ const ENTITY_PRIMITIVE_NAME = 'entity';
509
+
510
+ const matchEntityPrimitiveName = (
511
+ name: string | undefined | null
512
+ ): string | null => (name === ENTITY_PRIMITIVE_NAME ? name : null);
513
+
514
+ const getBareEntityCalleeName = (callee: AstNode): string | null => {
515
+ if (callee.type !== 'Identifier') {
516
+ return null;
517
+ }
518
+ return matchEntityPrimitiveName(
519
+ (callee as unknown as { name?: string }).name
520
+ );
521
+ };
522
+
523
+ /**
524
+ * Resolve a namespaced `ns.entity(...)` callee to its primitive name. Mirrors
525
+ * {@link getNamespacedTrailCalleeName}: the receiver identifier must resolve
526
+ * to an `@ontrails/*` namespace import, and — when a scope-aware
527
+ * `safeCallStarts` set is provided — the call site must not be shadowed by a
528
+ * local binding of the same name.
529
+ */
530
+ const getNamespacedEntityCalleeName = (
531
+ callExpr: AstNode,
532
+ callee: AstNode,
533
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
534
+ ): string | null => {
535
+ const names = getNamespacedMemberNames(callee);
536
+ if (!names) {
537
+ return null;
538
+ }
539
+ // Unlike the trail/signal variant, entity has no inline-resolution callers
540
+ // that legitimately invoke this without a FrameworkNamespaceContext, so the
541
+ // strict namespace gate stays on. If a future caller needs the permissive
542
+ // fallback, mirror the trail shape and add a regression test first.
543
+ const ctx = asNamespaceContext(context);
544
+ if (!ctx || !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
545
+ return null;
546
+ }
547
+ return matchEntityPrimitiveName(names.property);
548
+ };
549
+
550
+ /**
551
+ * Resolve the callee name of an entity call expression. Matches both bare
552
+ * `entity(...)` identifiers and namespaced `core.entity(...)` callees where
553
+ * the namespace comes from an `@ontrails/*` import and is unshadowed.
554
+ */
555
+ const getEntityCalleeName = (
556
+ node: AstNode,
557
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
558
+ ): string | null => {
559
+ if (node.type !== 'CallExpression') {
560
+ return null;
561
+ }
562
+ const callee = node['callee'] as AstNode | undefined;
563
+ if (!callee) {
564
+ return null;
565
+ }
566
+ return (
567
+ getBareEntityCalleeName(callee) ??
568
+ getNamespacedEntityCalleeName(node, callee, context)
569
+ );
570
+ };
571
+
572
+ export const extractEntityDefinition = (
573
+ node: AstNode,
574
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
575
+ ): Omit<EntityDefinition, 'bindingName'> | null => {
576
+ if (!getEntityCalleeName(node, context)) {
577
+ return null;
578
+ }
579
+
580
+ const args = node['arguments'] as readonly AstNode[] | undefined;
581
+ const [nameArg, shapeArg, optionsArg] = args ?? [];
582
+ const name = extractStringLiteral(nameArg);
583
+ if (!name || shapeArg?.type !== 'ObjectExpression') {
584
+ return null;
585
+ }
586
+
587
+ return {
588
+ call: node,
589
+ name,
590
+ options: optionsArg?.type === 'ObjectExpression' ? optionsArg : null,
591
+ shape: shapeArg,
592
+ start: node.start,
593
+ };
594
+ };
595
+
596
+ const getCallStartFromCandidate = (
597
+ node: AstNode | undefined
598
+ ): number | null => {
599
+ if (!node) {
600
+ return null;
601
+ }
602
+ if (node.type === 'CallExpression') {
603
+ return node.start;
604
+ }
605
+ if (node.type !== 'ExpressionStatement') {
606
+ return null;
607
+ }
608
+ const { expression } = node as unknown as { expression?: AstNode };
609
+ return expression?.type === 'CallExpression' ? expression.start : null;
610
+ };
611
+
612
+ // Statement forms that can directly contain a top-level entity call:
613
+ // `core.entity(...)` as a bare statement,
614
+ // `export const ... = core.entity(...)` (handled via VariableDeclarator),
615
+ // `export default core.entity(...);`.
616
+ const getCandidateCallHosts = (
617
+ statement: AstNode
618
+ ): readonly (AstNode | undefined)[] => {
619
+ if (
620
+ statement.type !== 'ExportNamedDeclaration' &&
621
+ statement.type !== 'ExportDefaultDeclaration'
622
+ ) {
623
+ return [statement];
624
+ }
625
+ const { declaration } = statement as unknown as {
626
+ declaration?: AstNode;
627
+ };
628
+ return [statement, declaration];
629
+ };
630
+
631
+ const getTopLevelCallStartsFrom = (statement: AstNode): readonly number[] => {
632
+ const hosts = getCandidateCallHosts(statement);
633
+ const starts: number[] = [];
634
+ for (const host of hosts) {
635
+ const start = getCallStartFromCandidate(host);
636
+ if (start !== null) {
637
+ starts.push(start);
638
+ }
639
+ }
640
+ return starts;
641
+ };
642
+
643
+ /**
644
+ * Collect the `start` offsets of `CallExpression` nodes that appear as
645
+ * top-level `ExpressionStatement`s in a program body — including inside a
646
+ * top-level `ExportNamedDeclaration` / `ExportDefaultDeclaration` wrapper.
647
+ * Used to discriminate top-level statement-form calls from inline nested
648
+ * calls when `topLevelOnly` is enabled.
649
+ */
650
+ const collectTopLevelStatementCallStarts = (
651
+ ast: AstNode
652
+ ): ReadonlySet<number> => {
653
+ const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
654
+ return new Set(body.flatMap(getTopLevelCallStartsFrom));
655
+ };
656
+
657
+ export interface FindEntityDefinitionsOptions {
658
+ /**
659
+ * When true, skip entity calls nested inside other expressions (e.g.
660
+ * `core.entity('inner', {...}).id()` used as a field of an outer entity).
661
+ * Top-level forms are still surfaced: both `const foo = entity(...)`
662
+ * declarations and bare `entity('name', {...});` statement-form calls that
663
+ * appear directly in the program body (optionally wrapped in `export`) are
664
+ * returned.
665
+ *
666
+ * Defaults to `false`: both top-level and inline entities are returned so
667
+ * that reference-site resolution can reach anonymous inline entities.
668
+ */
669
+ readonly topLevelOnly?: boolean;
670
+ }
671
+
672
+ /**
673
+ * Return every `entity('name', ...)` definition reachable from the AST, in
674
+ * source order, deduplicated by call-expression start offset.
675
+ *
676
+ * Includes both top-level bindings (`const user = entity('user', ...)`) and
677
+ * inline entity calls nested inside other expressions (e.g.
678
+ * `entity('outer', { inner: entity('inner', ...).id() })`). Inline entities
679
+ * carry no `bindingName` because they have no local binding — this asymmetry
680
+ * is why {@link collectNamedEntityIds} returns only the top-level subset
681
+ * while {@link collectEntityDefinitionIds} returns the full set.
682
+ *
683
+ * Pass `{ topLevelOnly: true }` via `options` to opt out of inline discovery
684
+ * without disturbing callers that rely on the default behavior.
685
+ *
686
+ * @remarks
687
+ * Supplying a pre-built `context` skips the second full-AST traversal inside
688
+ * `buildFrameworkNamespaceContext` — useful for callers (such as
689
+ * {@link collectEntityReferenceSites}) that already built one.
690
+ */
691
+ export const findEntityDefinitions = (
692
+ ast: AstNode,
693
+ context?: FrameworkNamespaceContext,
694
+ options?: FindEntityDefinitionsOptions
695
+ ): EntityDefinition[] => {
696
+ const definitions: EntityDefinition[] = [];
697
+ const seenStarts = new Set<number>();
698
+ const resolvedContext = context ?? buildFrameworkNamespaceContext(ast);
699
+ const topLevelOnly = options?.topLevelOnly === true;
700
+
701
+ const addEntityDefinition = (definition: EntityDefinition): void => {
702
+ if (seenStarts.has(definition.start)) {
703
+ return;
704
+ }
705
+
706
+ definitions.push(definition);
707
+ seenStarts.add(definition.start);
708
+ };
709
+
710
+ const addNamedEntityDefinition = (
711
+ id: AstNode | undefined,
712
+ init: AstNode | undefined
713
+ ): void => {
714
+ if (!init) {
715
+ return;
716
+ }
717
+
718
+ const definition = extractEntityDefinition(init, resolvedContext);
719
+ if (!definition) {
720
+ return;
721
+ }
722
+
723
+ const bindingName = extractBindingName(id);
724
+ if (bindingName) {
725
+ addEntityDefinition({ ...definition, bindingName });
726
+ return;
727
+ }
728
+
729
+ addEntityDefinition(definition);
730
+ };
731
+
732
+ // When `topLevelOnly` is set, collect the start offsets of call expressions
733
+ // that sit directly in the program body as `ExpressionStatement`s (optionally
734
+ // wrapped in `export`). These are top-level statement-form entity calls and
735
+ // should still surface alongside `VariableDeclarator` bindings; only calls
736
+ // nested inside other expressions are excluded.
737
+ const topLevelStatementCallStarts = topLevelOnly
738
+ ? collectTopLevelStatementCallStarts(ast)
739
+ : null;
740
+
741
+ walk(ast, (node) => {
742
+ if (node.type === 'VariableDeclarator') {
743
+ const { id, init } = node as unknown as {
744
+ readonly id?: AstNode;
745
+ readonly init?: AstNode;
746
+ };
747
+ addNamedEntityDefinition(id, init);
748
+ return;
749
+ }
750
+
751
+ if (
752
+ topLevelStatementCallStarts &&
753
+ !topLevelStatementCallStarts.has(node.start)
754
+ ) {
755
+ return;
756
+ }
757
+
758
+ const definition = extractEntityDefinition(node, resolvedContext);
759
+ if (definition) {
760
+ addEntityDefinition(definition);
761
+ }
762
+ });
763
+
764
+ return definitions.toSorted((left, right) => left.start - right.start);
765
+ };
766
+
767
+ const extractImplementationFromConfig = (config: AstNode): AstNode[] => {
768
+ const bodies: AstNode[] = [];
769
+ const properties = config['properties'] as readonly AstNode[] | undefined;
770
+ if (!properties) {
771
+ return bodies;
772
+ }
773
+ for (const prop of properties) {
774
+ if (
775
+ isProperty(prop) &&
776
+ identifierName(prop.key) === 'implementation' &&
777
+ isAstNode(prop.value)
778
+ ) {
779
+ bodies.push(prop.value);
780
+ }
781
+ }
782
+ return bodies;
783
+ };
784
+
785
+ /**
786
+ * Find `implementation:` property values.
787
+ *
788
+ * When given an ObjectExpression (trail config), returns only its direct `implementation:`
789
+ * properties. When given a full AST, finds trail definitions first and extracts
790
+ * `implementation:` from each config — in both cases ignoring nested `implementation:` properties
791
+ * (e.g. `meta: { implementation: ... }`).
792
+ */
793
+ export const findImplementationBodies = (node: AstNode): AstNode[] => {
794
+ if (node.type === 'ObjectExpression') {
795
+ return extractImplementationFromConfig(node);
796
+ }
797
+
798
+ // Full AST — find trail definitions and extract implementation from their configs
799
+ const bodies: AstNode[] = [];
800
+ for (const def of findTrailDefinitions(node)) {
801
+ bodies.push(...extractImplementationFromConfig(def.config));
802
+ }
803
+ return bodies;
804
+ };
805
+
806
+ /** Recognize direct `.implementation(...)` member calls in source code. */
807
+ export const isImplementationCall = (node: AstNode): boolean => {
808
+ if (node.type !== 'CallExpression') {
809
+ return false;
810
+ }
811
+ const callee = node['callee'] as AstNode | undefined;
812
+ if (!callee) {
813
+ return false;
814
+ }
815
+ if (
816
+ callee.type !== 'StaticMemberExpression' &&
817
+ callee.type !== 'MemberExpression'
818
+ ) {
819
+ return false;
820
+ }
821
+ const prop = (callee as unknown as { property?: AstNode }).property;
822
+ return (
823
+ prop?.type === 'Identifier' &&
824
+ (prop as unknown as { name: string }).name === 'implementation'
825
+ );
826
+ };