@ontrails/source 1.0.0-beta.41
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/CHANGELOG.md +53 -0
- package/README.md +80 -0
- package/package.json +29 -0
- package/src/edits.ts +57 -0
- package/src/index.ts +24 -0
- package/src/literals.ts +226 -0
- package/src/locations.ts +35 -0
- package/src/nodes.ts +650 -0
- package/src/parse.ts +55 -0
- package/src/scopes.ts +517 -0
- package/src/trails.ts +826 -0
- package/src/walk.ts +87 -0
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
|
+
};
|