@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.
- package/dist/server/index.js +23 -2
- package/dist/server/nodesPayload.d.ts +14 -1
- package/dist/server/nodesPayload.js +15 -2
- package/dist/server/projectMode.js +5 -2
- package/dist/server/sourceNav.d.ts +77 -0
- package/dist/server/sourceNav.js +503 -0
- package/dist/src/engine/registry.d.ts +27 -2
- package/dist/src/engine/registry.js +84 -2
- package/dist/src/nodes/index.d.ts +8 -2
- package/dist/src/nodes/index.js +7 -3
- package/dist/src/nodes/login.d.ts +3 -1
- package/dist/src/nodes/login.js +2 -2
- package/package.json +2 -2
- package/server/index.ts +24 -2
- package/server/nodesPayload.test.ts +38 -0
- package/server/nodesPayload.ts +28 -3
- package/server/projectMode.ts +5 -2
- package/server/sourceNav.test.ts +382 -0
- package/server/sourceNav.ts +644 -0
- package/server/sourceNavRoute.test.ts +163 -0
- package/src/components/Inspector.tsx +9 -26
- package/src/components/SourceNavigator.test.tsx +226 -0
- package/src/components/SourceNavigator.tsx +520 -0
- package/src/engine/registry.sourceRef.test.ts +112 -0
- package/src/engine/registry.ts +103 -2
- package/src/nodes/index.ts +10 -3
- package/src/nodes/login.ts +5 -2
- package/src/store/builderStore.ts +2 -2
- package/src/store/nodeMeta.ts +10 -2
- package/src/store/sourceNavClient.ts +48 -0
- package/studio-dist/assets/{index-CGwEx82J.js → index-BbzppDpt.js} +25 -25
- package/studio-dist/assets/{index-CAIDjNhv.css → index-CLf6blcA.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-basics/SKILL.md +29 -1
- package/templates/skills/emberflow-model-process/SKILL.md +11 -1
- package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
3
|
-
|
|
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;
|