@xdelivered/emberflow 0.4.0 → 0.5.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.
Files changed (37) hide show
  1. package/dist/server/index.js +23 -2
  2. package/dist/server/nodesPayload.d.ts +14 -1
  3. package/dist/server/nodesPayload.js +15 -2
  4. package/dist/server/projectMode.js +5 -2
  5. package/dist/server/sourceNav.d.ts +77 -0
  6. package/dist/server/sourceNav.js +503 -0
  7. package/dist/src/engine/registry.d.ts +27 -2
  8. package/dist/src/engine/registry.js +84 -2
  9. package/dist/src/nodes/index.d.ts +8 -2
  10. package/dist/src/nodes/index.js +7 -3
  11. package/dist/src/nodes/login.d.ts +3 -1
  12. package/dist/src/nodes/login.js +2 -2
  13. package/package.json +2 -2
  14. package/server/index.ts +24 -2
  15. package/server/nodesPayload.test.ts +38 -0
  16. package/server/nodesPayload.ts +28 -3
  17. package/server/projectMode.ts +5 -2
  18. package/server/sourceNav.test.ts +382 -0
  19. package/server/sourceNav.ts +644 -0
  20. package/server/sourceNavRoute.test.ts +163 -0
  21. package/src/components/Inspector.tsx +9 -26
  22. package/src/components/SourceNavigator.test.tsx +226 -0
  23. package/src/components/SourceNavigator.tsx +520 -0
  24. package/src/engine/registry.sourceRef.test.ts +112 -0
  25. package/src/engine/registry.ts +103 -2
  26. package/src/nodes/index.ts +10 -3
  27. package/src/nodes/login.ts +5 -2
  28. package/src/store/builderStore.ts +2 -2
  29. package/src/store/nodeMeta.ts +10 -2
  30. package/src/store/sourceNavClient.ts +48 -0
  31. package/studio-dist/assets/{index-CGwEx82J.js → index-BbzppDpt.js} +25 -25
  32. package/studio-dist/assets/{index-CAIDjNhv.css → index-CLf6blcA.css} +1 -1
  33. package/studio-dist/index.html +2 -2
  34. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  35. package/templates/skills/emberflow-model-process/SKILL.md +11 -1
  36. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  37. package/templates/skills/emberflow-review-workflow/SKILL.md +28 -1
@@ -0,0 +1,503 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { promises as fsp } from 'node:fs';
3
+ import { builtinModules } from 'node:module';
4
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { isPathWithin } from './pathSafety.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Lazy typescript module
8
+ let tsModulePromise;
9
+ async function loadTs(loader) {
10
+ if (loader) {
11
+ try {
12
+ return await loader();
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ if (!tsModulePromise) {
19
+ tsModulePromise = import('typescript').then((m) => (m.default ?? m));
20
+ }
21
+ try {
22
+ return await tsModulePromise;
23
+ }
24
+ catch {
25
+ tsModulePromise = undefined; // allow a later retry (e.g. install completed)
26
+ return undefined;
27
+ }
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Path guards
31
+ const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
32
+ function languageOf(file) {
33
+ return TS_EXTS.has(extname(file)) ? 'ts' : 'js';
34
+ }
35
+ /** Deny-list beyond isPathWithin: node_modules anywhere, secret basenames. */
36
+ function isRequestPathAllowed(projectRoot, rel) {
37
+ if (!isPathWithin(projectRoot, rel))
38
+ return false;
39
+ const segments = rel.split('/');
40
+ if (segments.includes('node_modules'))
41
+ return false;
42
+ const base = (segments[segments.length - 1] ?? '').toLowerCase();
43
+ if (base.startsWith('.env'))
44
+ return false;
45
+ if (base === 'emberflow.secrets.json')
46
+ return false;
47
+ if (base.endsWith('.pem') || base.endsWith('.key'))
48
+ return false;
49
+ return true;
50
+ }
51
+ const parseCache = new Map();
52
+ /** Clears all sourceNav caches (parse, tsconfig, ts module) — tests only. */
53
+ export function resetSourceNavCaches() {
54
+ parseCache.clear();
55
+ tsconfigCache.clear();
56
+ tsModulePromise = undefined;
57
+ }
58
+ function scriptKindFor(ts, file) {
59
+ switch (extname(file)) {
60
+ case '.ts':
61
+ case '.mts':
62
+ case '.cts':
63
+ return ts.ScriptKind.TS;
64
+ case '.tsx':
65
+ return ts.ScriptKind.TSX;
66
+ case '.jsx':
67
+ return ts.ScriptKind.JSX;
68
+ default:
69
+ return ts.ScriptKind.JS;
70
+ }
71
+ }
72
+ function parseFile(ts, absPath, content) {
73
+ const sf = ts.createSourceFile(absPath, content, ts.ScriptTarget.Latest,
74
+ /* setParentNodes */ true, scriptKindFor(ts, absPath));
75
+ const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
76
+ const hasExportModifier = (node) => {
77
+ const mods = node.modifiers;
78
+ return !!mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
79
+ };
80
+ const declarations = [];
81
+ const imports = [];
82
+ const reexports = [];
83
+ const dynamicImports = [];
84
+ /** Names exported post-hoc via a specifier-less `export { x }`. */
85
+ const localExports = new Set();
86
+ for (const stmt of sf.statements) {
87
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) {
88
+ declarations.push({
89
+ name: stmt.name.text,
90
+ kind: 'fn',
91
+ line: lineOf(stmt.getStart(sf)),
92
+ endLine: lineOf(stmt.end),
93
+ exported: hasExportModifier(stmt),
94
+ });
95
+ }
96
+ else if (ts.isClassDeclaration(stmt) && stmt.name) {
97
+ declarations.push({
98
+ name: stmt.name.text,
99
+ kind: 'class',
100
+ line: lineOf(stmt.getStart(sf)),
101
+ endLine: lineOf(stmt.end),
102
+ exported: hasExportModifier(stmt),
103
+ });
104
+ }
105
+ else if (ts.isVariableStatement(stmt)) {
106
+ for (const decl of stmt.declarationList.declarations) {
107
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
108
+ declarations.push({
109
+ name: decl.name.text,
110
+ kind: 'const',
111
+ line: lineOf(decl.getStart(sf)),
112
+ endLine: lineOf(stmt.end),
113
+ exported: hasExportModifier(stmt),
114
+ });
115
+ }
116
+ }
117
+ }
118
+ else if (ts.isInterfaceDeclaration(stmt) ||
119
+ ts.isTypeAliasDeclaration(stmt) ||
120
+ ts.isEnumDeclaration(stmt)) {
121
+ declarations.push({
122
+ name: stmt.name.text,
123
+ kind: 'other',
124
+ line: lineOf(stmt.getStart(sf)),
125
+ endLine: lineOf(stmt.end),
126
+ exported: hasExportModifier(stmt),
127
+ });
128
+ }
129
+ else if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
130
+ const from = stmt.moduleSpecifier.text;
131
+ const clause = stmt.importClause;
132
+ if (!clause) {
133
+ imports.push({ name: '*', local: '', from }); // side-effect import
134
+ continue;
135
+ }
136
+ if (clause.name)
137
+ imports.push({ name: 'default', local: clause.name.text, from });
138
+ const bindings = clause.namedBindings;
139
+ if (bindings) {
140
+ if (ts.isNamespaceImport(bindings)) {
141
+ imports.push({ name: '*', local: bindings.name.text, from });
142
+ }
143
+ else {
144
+ for (const el of bindings.elements) {
145
+ imports.push({ name: (el.propertyName ?? el.name).text, local: el.name.text, from });
146
+ }
147
+ }
148
+ }
149
+ }
150
+ else if (ts.isExportDeclaration(stmt)) {
151
+ const spec = stmt.moduleSpecifier;
152
+ if (spec && ts.isStringLiteral(spec)) {
153
+ const from = spec.text;
154
+ if (!stmt.exportClause) {
155
+ reexports.push({ exported: '*', from });
156
+ }
157
+ else if (ts.isNamespaceExport(stmt.exportClause)) {
158
+ reexports.push({ exported: stmt.exportClause.name.text, source: '*', from });
159
+ }
160
+ else {
161
+ for (const el of stmt.exportClause.elements) {
162
+ reexports.push({
163
+ exported: el.name.text,
164
+ source: (el.propertyName ?? el.name).text,
165
+ from,
166
+ });
167
+ }
168
+ }
169
+ }
170
+ else if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
171
+ for (const el of stmt.exportClause.elements) {
172
+ localExports.add((el.propertyName ?? el.name).text);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ // `export { x }` after the declaration marks it exported.
178
+ for (const d of declarations) {
179
+ if (!d.exported && localExports.has(d.name))
180
+ d.exported = true;
181
+ }
182
+ // Dynamic imports live anywhere in the tree.
183
+ const visit = (node) => {
184
+ if (ts.isCallExpression(node) &&
185
+ node.expression.kind === ts.SyntaxKind.ImportKeyword) {
186
+ const arg = node.arguments[0];
187
+ dynamicImports.push(arg && ts.isStringLiteral(arg) ? { from: arg.text } : {});
188
+ }
189
+ ts.forEachChild(node, visit);
190
+ };
191
+ visit(sf);
192
+ return { content, language: languageOf(absPath), declarations, imports, dynamicImports, reexports };
193
+ }
194
+ /** Parse (or reuse the cached parse of) an absolute path. Throws when unreadable. */
195
+ function getParsed(ts, absPath) {
196
+ const stat = statSync(absPath);
197
+ const cached = parseCache.get(absPath);
198
+ if (cached && cached.mtimeMs === stat.mtimeMs)
199
+ return cached.parsed;
200
+ const content = readFileSync(absPath, 'utf8');
201
+ const parsed = parseFile(ts, absPath, content);
202
+ parseCache.set(absPath, { mtimeMs: stat.mtimeMs, parsed });
203
+ return parsed;
204
+ }
205
+ function getParsedSafe(ts, absPath) {
206
+ try {
207
+ return getParsed(ts, absPath);
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // Specifier resolution
215
+ const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];
216
+ /** TS-style specifier mapping: an emitted-extension specifier may point at TS source. */
217
+ const JS_TO_TS = {
218
+ '.js': ['.ts', '.tsx'],
219
+ '.mjs': ['.mts'],
220
+ '.cjs': ['.cts'],
221
+ };
222
+ function isFile(p) {
223
+ try {
224
+ return statSync(p).isFile();
225
+ }
226
+ catch {
227
+ return false;
228
+ }
229
+ }
230
+ /** Extension ladder: exact → +ext → TS .js→.ts mapping → /index.* */
231
+ function resolveWithLadder(base) {
232
+ if (isFile(base))
233
+ return base;
234
+ for (const ext of RESOLVE_EXTS) {
235
+ if (isFile(base + ext))
236
+ return base + ext;
237
+ }
238
+ for (const [jsExt, tsExts] of Object.entries(JS_TO_TS)) {
239
+ if (base.endsWith(jsExt)) {
240
+ const stem = base.slice(0, -jsExt.length);
241
+ for (const tsExt of tsExts) {
242
+ if (isFile(stem + tsExt))
243
+ return stem + tsExt;
244
+ }
245
+ }
246
+ }
247
+ for (const ext of RESOLVE_EXTS) {
248
+ const idx = join(base, 'index' + ext);
249
+ if (isFile(idx))
250
+ return idx;
251
+ }
252
+ return undefined;
253
+ }
254
+ const tsconfigCache = new Map();
255
+ function tsconfigPathsFor(ts, projectRoot) {
256
+ const cfgPath = join(projectRoot, 'tsconfig.json');
257
+ let mtimeMs;
258
+ try {
259
+ mtimeMs = statSync(cfgPath).mtimeMs;
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ const cached = tsconfigCache.get(cfgPath);
265
+ if (cached && cached.mtimeMs === mtimeMs)
266
+ return cached.paths;
267
+ let paths;
268
+ try {
269
+ // ts.readConfigFile parses leniently — tsconfig.json is JSONC.
270
+ const { config } = ts.readConfigFile(cfgPath, (p) => readFileSync(p, 'utf8'));
271
+ const options = config
272
+ ?.compilerOptions;
273
+ const rawPaths = options?.paths;
274
+ if (rawPaths && typeof rawPaths === 'object') {
275
+ const entries = [];
276
+ for (const [pattern, targetsRaw] of Object.entries(rawPaths)) {
277
+ if (!Array.isArray(targetsRaw))
278
+ continue;
279
+ const targets = targetsRaw.filter((t) => typeof t === 'string');
280
+ const stars = pattern.split('*').length - 1;
281
+ if (stars === 0) {
282
+ entries.push({ prefix: '', suffix: '', exact: pattern, targets });
283
+ }
284
+ else if (stars === 1) {
285
+ const [prefix = '', suffix = ''] = pattern.split('*');
286
+ entries.push({ prefix, suffix, targets });
287
+ }
288
+ // multi-star patterns are invalid tsconfig anyway — ignored
289
+ }
290
+ // Longest prefix wins, mirroring TS's matching order.
291
+ entries.sort((a, b) => (b.exact ?? b.prefix).length - (a.exact ?? a.prefix).length);
292
+ paths = { base: resolve(projectRoot, options?.baseUrl ?? '.'), entries };
293
+ }
294
+ }
295
+ catch {
296
+ paths = undefined;
297
+ }
298
+ tsconfigCache.set(cfgPath, { mtimeMs, paths });
299
+ return paths;
300
+ }
301
+ function resolveViaTsconfigPaths(ts, projectRoot, spec) {
302
+ const cfg = tsconfigPathsFor(ts, projectRoot);
303
+ if (!cfg)
304
+ return undefined;
305
+ for (const entry of cfg.entries) {
306
+ let substituted;
307
+ if (entry.exact !== undefined) {
308
+ if (spec === entry.exact)
309
+ substituted = entry.targets;
310
+ }
311
+ else if (spec.startsWith(entry.prefix) &&
312
+ spec.endsWith(entry.suffix) &&
313
+ spec.length >= entry.prefix.length + entry.suffix.length) {
314
+ const middle = spec.slice(entry.prefix.length, spec.length - entry.suffix.length);
315
+ substituted = entry.targets.map((t) => t.replace('*', middle));
316
+ }
317
+ if (!substituted)
318
+ continue;
319
+ for (const target of substituted) {
320
+ const found = resolveWithLadder(resolve(cfg.base, target));
321
+ if (found)
322
+ return found;
323
+ }
324
+ }
325
+ return undefined;
326
+ }
327
+ const NODE_BUILTINS = new Set(builtinModules);
328
+ /** Repo-relative POSIX path when `abs` sits inside the root (and outside node_modules). */
329
+ function projectRelative(projectRoot, abs) {
330
+ const rel = relative(projectRoot, abs);
331
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
332
+ return undefined;
333
+ if (rel.split('/').includes('node_modules'))
334
+ return undefined;
335
+ return rel;
336
+ }
337
+ /**
338
+ * Resolve a specifier from `importerAbs`. Project results carry the ABSOLUTE
339
+ * target path in `absPath` (for chain following); callers convert to
340
+ * repo-relative for the response.
341
+ */
342
+ function resolveSpecifier(ts, projectRoot, importerAbs, spec) {
343
+ if (spec.startsWith('./') || spec.startsWith('../')) {
344
+ const target = resolveWithLadder(resolve(dirname(importerAbs), spec));
345
+ if (!target)
346
+ return { kind: 'unresolved', reason: `cannot resolve relative import '${spec}'` };
347
+ const rel = projectRelative(projectRoot, target);
348
+ if (!rel)
349
+ return { kind: 'unresolved', reason: 'resolves outside the project root' };
350
+ return { kind: 'project', path: rel, absPath: target };
351
+ }
352
+ if (isAbsolute(spec))
353
+ return { kind: 'unresolved', reason: 'absolute-path specifier' };
354
+ if (spec.startsWith('node:'))
355
+ return { kind: 'builtin' };
356
+ const viaPaths = resolveViaTsconfigPaths(ts, projectRoot, spec);
357
+ if (viaPaths) {
358
+ const rel = projectRelative(projectRoot, viaPaths);
359
+ if (!rel)
360
+ return { kind: 'unresolved', reason: 'resolves outside the project root' };
361
+ return { kind: 'project', path: rel, absPath: viaPaths };
362
+ }
363
+ if (NODE_BUILTINS.has(spec))
364
+ return { kind: 'builtin' };
365
+ const parts = spec.split('/');
366
+ const pkg = spec.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
367
+ return { kind: 'external', package: pkg };
368
+ }
369
+ // ---------------------------------------------------------------------------
370
+ // Re-export chain following
371
+ const REEXPORT_DEPTH_CAP = 8;
372
+ /**
373
+ * Find the file+line where `exportName` is actually DECLARED, starting at
374
+ * `absFile` and following named/star re-exports. Depth-capped, cycle-safe.
375
+ */
376
+ function findExportedDeclaration(ts, projectRoot, absFile, exportName, depth = 0, seen = new Set()) {
377
+ if (depth > REEXPORT_DEPTH_CAP)
378
+ return undefined;
379
+ const key = `${absFile}::${exportName}`;
380
+ if (seen.has(key))
381
+ return undefined;
382
+ seen.add(key);
383
+ const parsed = getParsedSafe(ts, absFile);
384
+ if (!parsed)
385
+ return undefined;
386
+ const decl = parsed.declarations.find((d) => d.exported && d.name === exportName) ??
387
+ parsed.declarations.find((d) => d.name === exportName);
388
+ if (decl)
389
+ return { absFile, line: decl.line };
390
+ const named = parsed.reexports.find((r) => r.exported === exportName && r.source && r.source !== '*');
391
+ if (named) {
392
+ const res = resolveSpecifier(ts, projectRoot, absFile, named.from);
393
+ if (res.kind === 'project' && res.absPath) {
394
+ return findExportedDeclaration(ts, projectRoot, res.absPath, named.source, depth + 1, seen);
395
+ }
396
+ return undefined;
397
+ }
398
+ for (const star of parsed.reexports.filter((r) => r.exported === '*')) {
399
+ const res = resolveSpecifier(ts, projectRoot, absFile, star.from);
400
+ if (res.kind !== 'project' || !res.absPath)
401
+ continue;
402
+ const found = findExportedDeclaration(ts, projectRoot, res.absPath, exportName, depth + 1, seen);
403
+ if (found)
404
+ return found;
405
+ }
406
+ return undefined;
407
+ }
408
+ /** Resolution for an import/re-export entry, chased to the declaring file when possible. */
409
+ function resolveEntry(ts, projectRoot, importerAbs, spec, symbolName) {
410
+ const res = resolveSpecifier(ts, projectRoot, importerAbs, spec);
411
+ if (res.kind !== 'project' || !res.absPath) {
412
+ const { absPath: _drop, ...pub } = res;
413
+ return pub;
414
+ }
415
+ if (symbolName && symbolName !== '*' && symbolName !== 'default') {
416
+ const found = findExportedDeclaration(ts, projectRoot, res.absPath, symbolName);
417
+ if (found) {
418
+ const rel = projectRelative(projectRoot, found.absFile);
419
+ if (rel)
420
+ return { kind: 'project', path: rel, line: found.line };
421
+ }
422
+ }
423
+ return { kind: 'project', path: res.path };
424
+ }
425
+ // ---------------------------------------------------------------------------
426
+ // Entry point
427
+ export async function getSourceFile(projectRoot, relPath, opts) {
428
+ const root = resolve(projectRoot);
429
+ // Generic 400: never echo the requested path back to the client.
430
+ if (typeof relPath !== 'string' || !isRequestPathAllowed(root, relPath)) {
431
+ return { ok: false, status: 400, error: 'Invalid or denied path' };
432
+ }
433
+ const abs = resolve(root, relPath);
434
+ let stat;
435
+ try {
436
+ stat = await fsp.stat(abs);
437
+ }
438
+ catch {
439
+ return { ok: false, status: 404, error: 'File not found' };
440
+ }
441
+ if (!stat.isFile())
442
+ return { ok: false, status: 404, error: 'File not found' };
443
+ const responsePath = relative(root, abs);
444
+ const ts = await loadTs(opts?.tsLoader);
445
+ if (!ts) {
446
+ const content = await fsp.readFile(abs, 'utf8');
447
+ return {
448
+ ok: true,
449
+ payload: {
450
+ path: responsePath,
451
+ content,
452
+ language: languageOf(abs),
453
+ resolver: 'unavailable',
454
+ symbols: { declarations: [], imports: [], reexports: [] },
455
+ },
456
+ };
457
+ }
458
+ let parsed;
459
+ try {
460
+ parsed = getParsed(ts, abs);
461
+ }
462
+ catch {
463
+ return { ok: false, status: 404, error: 'File not found' };
464
+ }
465
+ const imports = parsed.imports.map((raw) => ({
466
+ name: raw.name,
467
+ local: raw.local,
468
+ from: raw.from,
469
+ resolution: resolveEntry(ts, root, abs, raw.from, raw.name),
470
+ }));
471
+ for (const dyn of parsed.dynamicImports) {
472
+ if (dyn.from !== undefined) {
473
+ imports.push({
474
+ name: 'import()',
475
+ local: '',
476
+ from: dyn.from,
477
+ resolution: resolveEntry(ts, root, abs, dyn.from, undefined),
478
+ });
479
+ }
480
+ else {
481
+ imports.push({
482
+ name: 'import()',
483
+ local: '',
484
+ from: '(computed)',
485
+ resolution: { kind: 'unresolved', reason: 'dynamic import with a computed specifier' },
486
+ });
487
+ }
488
+ }
489
+ const reexports = parsed.reexports.map((raw) => ({
490
+ name: raw.exported,
491
+ from: raw.from,
492
+ resolution: resolveEntry(ts, root, abs, raw.from, raw.source && raw.source !== '*' ? raw.source : undefined),
493
+ }));
494
+ return {
495
+ ok: true,
496
+ payload: {
497
+ path: responsePath,
498
+ content: parsed.content,
499
+ language: parsed.language,
500
+ symbols: { declarations: parsed.declarations, imports, reexports },
501
+ },
502
+ };
503
+ }
@@ -3,10 +3,30 @@ export interface RegisteredNode {
3
3
  definition: NodeDefinition;
4
4
  implementation: NodeImplementation;
5
5
  }
6
+ /** Where a node was registered from: absolute file path + 1-based line of the register() call. */
7
+ export interface SourceRef {
8
+ file: string;
9
+ line?: number;
10
+ }
11
+ export interface RegisterOptions {
12
+ /** Explicit provenance escape hatch — wins over automatic capture. */
13
+ sourceRef?: SourceRef;
14
+ }
6
15
  export declare class NodeRegistry {
7
16
  nodes: Map<string, RegisteredNode>;
8
17
  sources: Map<string, string>;
9
- register(definition: NodeDefinition, implementation: NodeImplementation): void;
18
+ /** Registration provenance per node type. Populated only when capture is on or opts.sourceRef given. */
19
+ sourceRefs: Map<string, SourceRef>;
20
+ /** Types the RUNNER flagged builtin (registered from the package, not the project). */
21
+ builtinTypes: Set<string>;
22
+ /** Automatic caller capture is opt-in (server registries only) so browser registries never pay/leak. */
23
+ private captureSourceRefs;
24
+ constructor(opts?: {
25
+ captureSourceRefs?: boolean;
26
+ });
27
+ register(definition: NodeDefinition, implementation: NodeImplementation, opts?: RegisterOptions): void;
28
+ /** Where `type` was registered from, when known. */
29
+ getSourceRef(type: string): SourceRef | undefined;
10
30
  get(type: string): RegisteredNode;
11
31
  has(type: string): boolean;
12
32
  /**
@@ -17,13 +37,18 @@ export declare class NodeRegistry {
17
37
  * process restart (which would kill in-flight agent runs).
18
38
  */
19
39
  adopt(other: NodeRegistry): void;
40
+ /** Whether the runner flagged `type` as a package built-in (no servable source file). */
41
+ isBuiltin(type: string): boolean;
20
42
  list(): NodeDefinition[];
21
43
  /**
22
44
  * Register a definition-only node (metadata fetched from the runner). Its
23
45
  * implementation is a stub that fails loudly if browser-executed — such
24
46
  * nodes run on the server. Never overwrites a real registration.
25
47
  */
26
- registerDefinition(definition: NodeDefinition, source?: string): void;
48
+ registerDefinition(definition: NodeDefinition, source?: string, opts?: {
49
+ sourceRef?: SourceRef;
50
+ builtin?: boolean;
51
+ }): void;
27
52
  /** Source text for display: stored source for definition-only nodes, else the impl's toString(). */
28
53
  getSource(type: string): string | undefined;
29
54
  /**
@@ -1,11 +1,78 @@
1
+ /**
2
+ * Read the CALLER's file:line via a one-frame V8 stack capture.
3
+ * `Error.prepareStackTrace` is swapped for the duration of a single `new
4
+ * Error()` and restored in `finally`, so source-map hooks installed by
5
+ * tsx/vitest are back in place immediately. Frames arrive either as
6
+ * `file:///abs/path.ts` URLs (ESM) or plain absolute paths (CJS/transpilers);
7
+ * both normalize to a plain path. Returns undefined on any non-V8 runtime or
8
+ * anonymous/eval frames — capture is best-effort by design.
9
+ */
10
+ function captureCallerSourceRef(skip) {
11
+ if (typeof Error.captureStackTrace !== 'function')
12
+ return undefined;
13
+ const original = Error.prepareStackTrace;
14
+ try {
15
+ Error.prepareStackTrace = (_err, frames) => frames;
16
+ const holder = {};
17
+ Error.captureStackTrace(holder, skip);
18
+ const frames = holder.stack;
19
+ if (!Array.isArray(frames) || frames.length === 0)
20
+ return undefined;
21
+ const frame = frames[0];
22
+ const raw = typeof frame.getFileName === 'function' ? frame.getFileName() : undefined;
23
+ if (typeof raw !== 'string' || raw.length === 0)
24
+ return undefined;
25
+ const lineRaw = typeof frame.getLineNumber === 'function' ? frame.getLineNumber() : undefined;
26
+ const line = typeof lineRaw === 'number' && lineRaw > 0 ? lineRaw : undefined;
27
+ let file = raw;
28
+ if (file.startsWith('file://')) {
29
+ // No node:url here (browser-safe module): URL parsing covers the
30
+ // file:///abs/path form these frames take on POSIX platforms.
31
+ try {
32
+ file = decodeURIComponent(new URL(file).pathname);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ // Loaders (vite-node hot imports) may suffix ?t=... cache-busters.
39
+ const q = file.indexOf('?');
40
+ if (q !== -1)
41
+ file = file.slice(0, q);
42
+ return line !== undefined ? { file, line } : { file };
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ finally {
48
+ Error.prepareStackTrace = original;
49
+ }
50
+ }
1
51
  export class NodeRegistry {
2
52
  nodes = new Map();
3
53
  sources = new Map();
4
- register(definition, implementation) {
54
+ /** Registration provenance per node type. Populated only when capture is on or opts.sourceRef given. */
55
+ sourceRefs = new Map();
56
+ /** Types the RUNNER flagged builtin (registered from the package, not the project). */
57
+ builtinTypes = new Set();
58
+ /** Automatic caller capture is opt-in (server registries only) so browser registries never pay/leak. */
59
+ captureSourceRefs;
60
+ constructor(opts) {
61
+ this.captureSourceRefs = opts?.captureSourceRefs ?? false;
62
+ }
63
+ register(definition, implementation, opts) {
5
64
  if (this.nodes.has(definition.type)) {
6
65
  throw new Error(`Node type already registered: ${definition.type}`);
7
66
  }
8
67
  this.nodes.set(definition.type, { definition, implementation });
68
+ const ref = opts?.sourceRef ??
69
+ (this.captureSourceRefs ? captureCallerSourceRef(this.register) : undefined);
70
+ if (ref)
71
+ this.sourceRefs.set(definition.type, ref);
72
+ }
73
+ /** Where `type` was registered from, when known. */
74
+ getSourceRef(type) {
75
+ return this.sourceRefs.get(type);
9
76
  }
10
77
  get(type) {
11
78
  const node = this.nodes.get(type);
@@ -26,6 +93,12 @@ export class NodeRegistry {
26
93
  adopt(other) {
27
94
  this.nodes = other.nodes;
28
95
  this.sources = other.sources;
96
+ this.sourceRefs = other.sourceRefs;
97
+ this.builtinTypes = other.builtinTypes;
98
+ }
99
+ /** Whether the runner flagged `type` as a package built-in (no servable source file). */
100
+ isBuiltin(type) {
101
+ return this.builtinTypes.has(type);
29
102
  }
30
103
  list() {
31
104
  return [...this.nodes.values()].map((n) => n.definition);
@@ -35,7 +108,14 @@ export class NodeRegistry {
35
108
  * implementation is a stub that fails loudly if browser-executed — such
36
109
  * nodes run on the server. Never overwrites a real registration.
37
110
  */
38
- registerDefinition(definition, source) {
111
+ registerDefinition(definition, source, opts) {
112
+ // Provenance is recorded even for already-registered types: the studio's
113
+ // bundled built-ins are real registrations, and the runner's builtin flag
114
+ // (or a project sourceRef shadowing one) must still land on them.
115
+ if (opts?.sourceRef)
116
+ this.sourceRefs.set(definition.type, opts.sourceRef);
117
+ if (opts?.builtin)
118
+ this.builtinTypes.add(definition.type);
39
119
  if (this.nodes.has(definition.type))
40
120
  return;
41
121
  this.nodes.set(definition.type, {
@@ -62,6 +142,8 @@ export class NodeRegistry {
62
142
  const next = new NodeRegistry();
63
143
  next.nodes = this.nodes;
64
144
  next.sources = this.sources;
145
+ next.sourceRefs = this.sourceRefs;
146
+ next.builtinTypes = this.builtinTypes;
65
147
  return next;
66
148
  }
67
149
  }
@@ -1,3 +1,9 @@
1
1
  import type { NodeRegistry } from '../engine/index.js';
2
- /** All built-in nodes: login examples, Open-Meteo weather, anomaly-detection API, EV charging demo, HTTP Response, requireAuth. */
3
- export declare function createDefaultRegistry(delayMs?: number): NodeRegistry;
2
+ /**
3
+ * All built-in nodes: login examples, Open-Meteo weather, anomaly-detection API, EV charging demo, HTTP Response, requireAuth.
4
+ * `opts.captureSourceRefs` is threaded from SERVER callers only (buildRegistries)
5
+ * — this function is shared with the browser bundle, where capture stays off.
6
+ */
7
+ export declare function createDefaultRegistry(delayMs?: number, opts?: {
8
+ captureSourceRefs?: boolean;
9
+ }): NodeRegistry;