@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
@@ -6,9 +6,13 @@ import { registerLoopNodes } from './loops.js';
6
6
  import { registerPradarNodesModule } from './pradar/index.js';
7
7
  import { registerResponseNodes } from './response.js';
8
8
  import { registerRequireAuthNode } from './requireAuth.js';
9
- /** All built-in nodes: login examples, Open-Meteo weather, anomaly-detection API, EV charging demo, HTTP Response, requireAuth. */
10
- export function createDefaultRegistry(delayMs) {
11
- const registry = createLoginRegistry(delayMs);
9
+ /**
10
+ * All built-in nodes: login examples, Open-Meteo weather, anomaly-detection API, EV charging demo, HTTP Response, requireAuth.
11
+ * `opts.captureSourceRefs` is threaded from SERVER callers only (buildRegistries)
12
+ * — this function is shared with the browser bundle, where capture stays off.
13
+ */
14
+ export function createDefaultRegistry(delayMs, opts) {
15
+ const registry = createLoginRegistry(delayMs, opts);
12
16
  registerWeatherNodes(registry);
13
17
  registerAnomalyNodes(registry);
14
18
  registerFlowControlNodes(registry);
@@ -4,4 +4,6 @@ import { NodeRegistry } from '../engine/index.js';
4
4
  * `delayMs` is injected into every implementation via `await sleep(delayMs)`
5
5
  * so tests can run with no delay by passing `0`.
6
6
  */
7
- export declare function createLoginRegistry(delayMs?: number): NodeRegistry;
7
+ export declare function createLoginRegistry(delayMs?: number, opts?: {
8
+ captureSourceRefs?: boolean;
9
+ }): NodeRegistry;
@@ -9,8 +9,8 @@ function usernamePart(userId) {
9
9
  * `delayMs` is injected into every implementation via `await sleep(delayMs)`
10
10
  * so tests can run with no delay by passing `0`.
11
11
  */
12
- export function createLoginRegistry(delayMs = 300) {
13
- const registry = new NodeRegistry();
12
+ export function createLoginRegistry(delayMs = 300, opts) {
13
+ const registry = new NodeRegistry(opts);
14
14
  registry.register({
15
15
  type: 'ValidateCredentials',
16
16
  label: 'Validate Credentials',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdelivered/emberflow",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Visual builder and runner for API operations modelled as node graphs, with scenarios, mocks and agent skills.",
6
6
  "keywords": [
@@ -86,6 +86,7 @@
86
86
  "tailwind-merge": "^3.6.0",
87
87
  "tailwindcss": "^4.3.2",
88
88
  "tw-animate-css": "^1.4.0",
89
+ "typescript": "~6.0.2",
89
90
  "zustand": "^5.0.14"
90
91
  },
91
92
  "peerDependencies": {
@@ -106,7 +107,6 @@
106
107
  "@vitejs/plugin-react": "^6.0.3",
107
108
  "oxlint": "^1.71.0",
108
109
  "tsx": "^4.22.5",
109
- "typescript": "~6.0.2",
110
110
  "vite": "^8.1.1",
111
111
  "vitest": "^4.1.9"
112
112
  }
package/server/index.ts CHANGED
@@ -32,6 +32,7 @@ import { loadInfrastructure } from './infrastructure';
32
32
  import { configPathFor, loadProjectConfig } from './projectConfig';
33
33
  import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './projectMode';
34
34
  import { nodesPayload } from './nodesPayload';
35
+ import { getSourceFile } from './sourceNav';
35
36
  import { openBrowser } from './openBrowser';
36
37
  import { AgentRunManager, AgentStartError } from './agents/runManager';
37
38
  import { isGitRepo } from './agents/gitScope';
@@ -159,7 +160,7 @@ const agentRuns = new AgentRunManager(
159
160
  project ? project.root : projectDir,
160
161
  apiStore.dir,
161
162
  apiStore.pathOf.bind(apiStore),
162
- () => nodesPayload(validationRegistry).nodes.map(({ type, label, description }) => ({ type, label, description })),
163
+ () => nodesPayload(validationRegistry, project ? project.root : process.cwd()).nodes.map(({ type, label, description }) => ({ type, label, description })),
163
164
  project?.language ?? 'typescript',
164
165
  // Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
165
166
  // earlier this session primes later prompts. Malformed → null (agent guesses).
@@ -253,7 +254,28 @@ api.post('/serving', (req: Request, res: Response) => {
253
254
  });
254
255
 
255
256
  api.get('/nodes', (_req, res) => {
256
- res.json(nodesPayload(validationRegistry));
257
+ res.json(nodesPayload(validationRegistry, project ? project.root : process.cwd()));
258
+ });
259
+
260
+ // Whole-file source view + symbol table for the studio's source navigator.
261
+ // Works in project mode AND no-project mode (root = cwd) — path guards in
262
+ // sourceNav (isPathWithin, node_modules, secret basenames) bound what is
263
+ // servable either way. 400s stay generic: the requested path is never echoed.
264
+ api.get('/source-file', (req: Request, res: Response) => {
265
+ void (async () => {
266
+ const path = req.query.path;
267
+ if (typeof path !== 'string' || path.length === 0) {
268
+ res.status(400).json({ error: 'Query param path is required' });
269
+ return;
270
+ }
271
+ const root = project ? project.root : process.cwd();
272
+ const result = await getSourceFile(root, path);
273
+ if (!result.ok) {
274
+ res.status(result.status).json({ error: result.error });
275
+ return;
276
+ }
277
+ res.json(result.payload);
278
+ })();
257
279
  });
258
280
 
259
281
  // Validate a flow against the runner's LIVE registry (built-ins + project
@@ -26,4 +26,42 @@ describe('nodesPayload', () => {
26
26
  expect(round.nodes[0].type).toBe('A');
27
27
  expect(typeof round.nodes[0].source).toBe('string');
28
28
  });
29
+
30
+ it('adds a repo-relative sourceRef when the captured file is inside the project root', () => {
31
+ const r = new NodeRegistry();
32
+ r.register({ type: 'X', label: 'X' }, async () => ({}), {
33
+ sourceRef: { file: '/proj/nodes/logic.mjs', line: 12 },
34
+ });
35
+ const node = nodesPayload(r, '/proj').nodes[0];
36
+ expect(node.sourceRef).toEqual({ file: 'nodes/logic.mjs', line: 12 });
37
+ expect(node.builtin).toBeUndefined();
38
+ });
39
+
40
+ it('flags builtin: true when the sourceRef points outside the project root', () => {
41
+ const r = new NodeRegistry();
42
+ r.register({ type: 'Y', label: 'Y' }, async () => ({}), {
43
+ sourceRef: { file: '/somewhere/else/pkg/node.ts', line: 3 },
44
+ });
45
+ const node = nodesPayload(r, '/proj').nodes[0];
46
+ expect(node.builtin).toBe(true);
47
+ expect(node.sourceRef).toBeUndefined();
48
+ });
49
+
50
+ it('omits sourceRef and builtin entirely when nothing was captured', () => {
51
+ const r = new NodeRegistry();
52
+ r.register({ type: 'Z', label: 'Z' }, async () => ({}));
53
+ const node = nodesPayload(r, '/proj').nodes[0];
54
+ expect('sourceRef' in node).toBe(false);
55
+ expect('builtin' in node).toBe(false);
56
+ });
57
+
58
+ it('preserves the source (toString) field alongside sourceRef', () => {
59
+ const r = new NodeRegistry();
60
+ r.register({ type: 'W', label: 'W' }, async () => ({ shout: true }), {
61
+ sourceRef: { file: '/proj/w.mjs', line: 1 },
62
+ });
63
+ const node = nodesPayload(r, '/proj').nodes[0];
64
+ expect(node.source).toContain('shout');
65
+ expect(node.sourceRef).toEqual({ file: 'w.mjs', line: 1 });
66
+ });
29
67
  });
@@ -1,3 +1,4 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
1
2
  import type { NodeDefinition, NodeRegistry } from '../src/engine';
2
3
 
3
4
  /**
@@ -6,12 +7,27 @@ import type { NodeDefinition, NodeRegistry } from '../src/engine';
6
7
  * implementation's toString() so the Inspector can show a node's code without
7
8
  * the browser bundling the implementation — the key to consumer nodes
8
9
  * appearing in the palette without a per-project browser build.
10
+ *
11
+ * `sourceRef` (repo-relative file + line of the register() call) is present
12
+ * only when registration provenance was captured AND the file lives inside
13
+ * the project root — the studio uses it to open the real source via
14
+ * GET /source-file. A captured ref OUTSIDE the root (Emberflow's own
15
+ * built-ins registered from the package) is flagged `builtin: true` instead:
16
+ * those files are never served (node_modules exposure rule), so the
17
+ * Inspector keeps the toString() view for them.
9
18
  */
10
19
  export interface NodeMetaPayload {
11
- nodes: Array<NodeDefinition & { source?: string }>;
20
+ nodes: Array<
21
+ NodeDefinition & {
22
+ source?: string;
23
+ sourceRef?: { file: string; line?: number };
24
+ builtin?: boolean;
25
+ }
26
+ >;
12
27
  }
13
28
 
14
- export function nodesPayload(registry: NodeRegistry): NodeMetaPayload {
29
+ export function nodesPayload(registry: NodeRegistry, projectRoot?: string): NodeMetaPayload {
30
+ const root = resolve(projectRoot ?? process.cwd());
15
31
  return {
16
32
  nodes: registry.list().map((definition) => {
17
33
  let source: string | undefined;
@@ -20,7 +36,16 @@ export function nodesPayload(registry: NodeRegistry): NodeMetaPayload {
20
36
  } catch {
21
37
  source = undefined;
22
38
  }
23
- return { ...definition, source };
39
+ const ref = registry.getSourceRef(definition.type);
40
+ if (!ref) return { ...definition, source };
41
+ const rel = relative(root, resolve(root, ref.file));
42
+ const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
43
+ if (!inside) return { ...definition, source, builtin: true };
44
+ return {
45
+ ...definition,
46
+ source,
47
+ sourceRef: ref.line !== undefined ? { file: rel, line: ref.line } : { file: rel },
48
+ };
24
49
  }),
25
50
  };
26
51
  }
@@ -49,8 +49,11 @@ export function buildRegistries(project: ProjectConfig | null): {
49
49
  validation: NodeRegistry;
50
50
  execution: NodeRegistry;
51
51
  } {
52
- const validation = createDefaultRegistry();
53
- const execution = createDefaultRegistry();
52
+ // Server-side registries capture registration provenance (file:line of each
53
+ // register() call) so the studio can navigate to a node's real source.
54
+ // Browser registries never enable this — see createDefaultRegistry.
55
+ const validation = createDefaultRegistry(undefined, { captureSourceRefs: true });
56
+ const execution = createDefaultRegistry(undefined, { captureSourceRefs: true });
54
57
  if (project?.registerNodes) {
55
58
  project.registerNodes(validation);
56
59
  project.registerNodes(execution);
@@ -0,0 +1,382 @@
1
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6
+ import { getSourceFile, resetSourceNavCaches, type SourceFilePayload } from './sourceNav';
7
+ import { buildRegistries } from './projectMode';
8
+ import { nodesPayload } from './nodesPayload';
9
+ import type { NodeRegistry } from '../src/engine';
10
+
11
+ let root: string;
12
+
13
+ function write(rel: string, lines: string[]): void {
14
+ const p = join(root, rel);
15
+ mkdirSync(join(p, '..'), { recursive: true });
16
+ writeFileSync(p, lines.join('\n') + '\n');
17
+ }
18
+
19
+ async function payloadOf(rel: string): Promise<SourceFilePayload> {
20
+ const result = await getSourceFile(root, rel);
21
+ if (!result.ok) throw new Error(`expected ok for ${rel}, got ${result.status}: ${result.error}`);
22
+ return result.payload;
23
+ }
24
+
25
+ beforeAll(() => {
26
+ root = realpathSync(mkdtempSync(join(tmpdir(), 'sourcenav-')));
27
+ resetSourceNavCaches();
28
+
29
+ write('nodes.mjs', [
30
+ "import { deriveTrackingActual } from './shipment-logic.mjs';", // 1
31
+ "import { createHash } from 'node:crypto';", // 2
32
+ "import express from 'express';", // 3
33
+ "import { helper } from '@scope/pkg/utils';", // 4
34
+ '', // 5
35
+ 'const TAX_RATE = 0.2;', // 6
36
+ '', // 7
37
+ 'export function handler(input) {', // 8
38
+ " void createHash('sha256');", // 9
39
+ ' void express;', // 10
40
+ ' void helper;', // 11
41
+ ' return deriveTrackingActual(input) * TAX_RATE;', // 12
42
+ '}', // 13
43
+ '', // 14
44
+ 'export async function loadPlugin(name) {', // 15
45
+ ' return await import(name);', // 16
46
+ '}', // 17
47
+ ]);
48
+
49
+ write('shipment-logic.mjs', [
50
+ 'function nestedHelper(shipment) {', // 1
51
+ ' return shipment.weight * 2;', // 2
52
+ '}', // 3
53
+ '', // 4
54
+ 'export function deriveTrackingActual(shipment) {', // 5
55
+ ' return nestedHelper(shipment);', // 6
56
+ '}', // 7
57
+ ]);
58
+
59
+ write('index.mjs', [
60
+ "export { deriveTrackingActual } from './shipment-logic.mjs';", // 1
61
+ "export * from './extras.mjs';", // 2
62
+ ]);
63
+ write('extras.mjs', ['export const EXTRA = 1;']);
64
+
65
+ write('via-index.mjs', [
66
+ "import { deriveTrackingActual } from './index.mjs';", // 1
67
+ '', // 2
68
+ 'export function viaIndex(x) {', // 3
69
+ ' return deriveTrackingActual(x);', // 4
70
+ '}', // 5
71
+ ]);
72
+
73
+ // Mutual value-import cycle.
74
+ write('circ-a.mjs', [
75
+ "import { b } from './circ-b.mjs';",
76
+ 'export function a() {',
77
+ ' return b();',
78
+ '}',
79
+ ]);
80
+ write('circ-b.mjs', [
81
+ "import { a } from './circ-a.mjs';",
82
+ 'export function b() {',
83
+ ' return a();',
84
+ '}',
85
+ ]);
86
+
87
+ // Re-export cycle: chasing the declaration must terminate.
88
+ write('reexp-a.mjs', ["export { spin } from './reexp-b.mjs';"]);
89
+ write('reexp-b.mjs', ["export { spin } from './reexp-a.mjs';"]);
90
+ write('spin-consumer.mjs', [
91
+ "import { spin } from './reexp-a.mjs';",
92
+ 'export const useSpin = spin;',
93
+ ]);
94
+
95
+ // TS variant with type annotations + TS-style .js specifier for a .ts file.
96
+ write('ts/handler.ts', [
97
+ "import { deriveTs } from './logic.js';", // 1
98
+ '', // 2
99
+ 'export function tsHandler(n: number): number {', // 3
100
+ ' return deriveTs(n);', // 4
101
+ '}', // 5
102
+ ]);
103
+ write('ts/logic.ts', [
104
+ 'export function deriveTs(n: number): number {', // 1
105
+ ' return n * 2;', // 2
106
+ '}', // 3
107
+ ]);
108
+
109
+ // tsconfig paths alias (JSONC — comments must parse leniently).
110
+ write('tsconfig.json', [
111
+ '{',
112
+ ' // single-star paths alias, exercised by aliased.ts',
113
+ ' "compilerOptions": {',
114
+ ' "paths": { "@lib/*": ["lib/*"] }',
115
+ ' }',
116
+ '}',
117
+ ]);
118
+ write('lib/util.ts', ['export function util(): number {', ' return 1;', '}']);
119
+ write('aliased.ts', ["import { util } from '@lib/util';", '', 'export const z = util();']);
120
+
121
+ // Secrets / denied files that must never be served.
122
+ write('.env', ['SECRET=shh']);
123
+ mkdirSync(join(root, 'sub'), { recursive: true });
124
+ write('sub/.env.local', ['SECRET=shh']);
125
+ write('server.key', ['---KEY---']);
126
+ write('cert.pem', ['---PEM---']);
127
+ write('emberflow.secrets.json', ['{}']);
128
+ mkdirSync(join(root, 'node_modules', 'somepkg'), { recursive: true });
129
+ write('node_modules/somepkg/index.js', ['module.exports = 1;']);
130
+ });
131
+
132
+ afterAll(() => {
133
+ if (root) rmSync(root, { recursive: true, force: true });
134
+ });
135
+
136
+ describe('getSourceFile: parse + declarations', () => {
137
+ it('serves the whole file with language and repo-relative path', async () => {
138
+ const p = await payloadOf('nodes.mjs');
139
+ expect(p.path).toBe('nodes.mjs');
140
+ expect(p.language).toBe('js');
141
+ expect(p.content).toContain('TAX_RATE');
142
+ expect(p.resolver).toBeUndefined();
143
+ });
144
+
145
+ it('extracts top-level declarations with kind, lines, and exported flag', async () => {
146
+ const p = await payloadOf('shipment-logic.mjs');
147
+ const decls = p.symbols.declarations;
148
+ const nested = decls.find((d) => d.name === 'nestedHelper');
149
+ expect(nested).toMatchObject({ kind: 'fn', line: 1, endLine: 3, exported: false });
150
+ const derive = decls.find((d) => d.name === 'deriveTrackingActual');
151
+ expect(derive).toMatchObject({ kind: 'fn', line: 5, endLine: 7, exported: true });
152
+ });
153
+
154
+ it('extracts a local const with an initializer', async () => {
155
+ const p = await payloadOf('nodes.mjs');
156
+ const tax = p.symbols.declarations.find((d) => d.name === 'TAX_RATE');
157
+ expect(tax).toMatchObject({ kind: 'const', line: 6, exported: false });
158
+ });
159
+ });
160
+
161
+ describe('getSourceFile: import resolution', () => {
162
+ it('resolves a relative sibling import to a project file with the declaration line', async () => {
163
+ const p = await payloadOf('nodes.mjs');
164
+ const imp = p.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
165
+ expect(imp).toBeDefined();
166
+ expect(imp!.from).toBe('./shipment-logic.mjs');
167
+ expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
168
+ });
169
+
170
+ it('classifies node:crypto as builtin', async () => {
171
+ const p = await payloadOf('nodes.mjs');
172
+ const imp = p.symbols.imports.find((i) => i.local === 'createHash');
173
+ expect(imp!.resolution).toEqual({ kind: 'builtin' });
174
+ });
175
+
176
+ it('classifies a bare specifier as external with the package name', async () => {
177
+ const p = await payloadOf('nodes.mjs');
178
+ const imp = p.symbols.imports.find((i) => i.local === 'express');
179
+ expect(imp!.name).toBe('default');
180
+ expect(imp!.resolution).toEqual({ kind: 'external', package: 'express' });
181
+ });
182
+
183
+ it('extracts the scoped package name from a deep specifier', async () => {
184
+ const p = await payloadOf('nodes.mjs');
185
+ const imp = p.symbols.imports.find((i) => i.local === 'helper');
186
+ expect(imp!.resolution).toEqual({ kind: 'external', package: '@scope/pkg' });
187
+ });
188
+
189
+ it('reports a computed dynamic import as unresolved with a reason', async () => {
190
+ const p = await payloadOf('nodes.mjs');
191
+ const dyn = p.symbols.imports.find((i) => i.resolution.kind === 'unresolved');
192
+ expect(dyn).toBeDefined();
193
+ expect((dyn!.resolution as { reason: string }).reason).toMatch(/dynamic/i);
194
+ });
195
+
196
+ it('follows a re-export chain through an index module to the declaring file', async () => {
197
+ const p = await payloadOf('via-index.mjs');
198
+ const imp = p.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
199
+ expect(imp!.from).toBe('./index.mjs');
200
+ expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
201
+ });
202
+
203
+ it('handles a circular import pair without looping', async () => {
204
+ const p = await payloadOf('circ-a.mjs');
205
+ const imp = p.symbols.imports.find((i) => i.local === 'b');
206
+ expect(imp!.resolution).toMatchObject({ kind: 'project', path: 'circ-b.mjs' });
207
+ });
208
+
209
+ it('terminates on a re-export cycle (depth cap + cycle set)', async () => {
210
+ const p = await payloadOf('spin-consumer.mjs');
211
+ const imp = p.symbols.imports.find((i) => i.local === 'spin');
212
+ expect(imp!.resolution.kind).toBe('project');
213
+ });
214
+
215
+ it('maps a TS-style .js specifier to the .ts source (extension ladder)', async () => {
216
+ const p = await payloadOf('ts/handler.ts');
217
+ expect(p.language).toBe('ts');
218
+ const imp = p.symbols.imports.find((i) => i.local === 'deriveTs');
219
+ expect(imp!.resolution).toEqual({ kind: 'project', path: 'ts/logic.ts', line: 1 });
220
+ });
221
+
222
+ it('honors single-star tsconfig paths (parsed leniently as JSONC)', async () => {
223
+ const p = await payloadOf('aliased.ts');
224
+ const imp = p.symbols.imports.find((i) => i.local === 'util');
225
+ expect(imp!.resolution).toEqual({ kind: 'project', path: 'lib/util.ts', line: 1 });
226
+ });
227
+ });
228
+
229
+ describe('getSourceFile: re-exports of the served file', () => {
230
+ it('lists named and star re-exports with resolutions', async () => {
231
+ const p = await payloadOf('index.mjs');
232
+ const named = p.symbols.reexports.find((r) => r.name === 'deriveTrackingActual');
233
+ expect(named!.from).toBe('./shipment-logic.mjs');
234
+ expect(named!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
235
+ const star = p.symbols.reexports.find((r) => r.name === '*');
236
+ expect(star!.from).toBe('./extras.mjs');
237
+ expect(star!.resolution).toMatchObject({ kind: 'project', path: 'extras.mjs' });
238
+ });
239
+ });
240
+
241
+ describe('getSourceFile: path guards', () => {
242
+ const denied = [
243
+ '../../etc/passwd',
244
+ '/etc/passwd',
245
+ 'node_modules/somepkg/index.js',
246
+ '.env',
247
+ 'sub/.env.local',
248
+ 'server.key',
249
+ 'cert.pem',
250
+ 'emberflow.secrets.json',
251
+ ];
252
+ for (const rel of denied) {
253
+ it(`denies ${rel} with a generic 400`, async () => {
254
+ const result = await getSourceFile(root, rel);
255
+ expect(result.ok).toBe(false);
256
+ if (result.ok) return;
257
+ expect(result.status).toBe(400);
258
+ // Generic message: never echo the requested path back.
259
+ expect(result.error).not.toContain(rel);
260
+ });
261
+ }
262
+
263
+ it('404s for a missing file inside the root', async () => {
264
+ const result = await getSourceFile(root, 'does-not-exist.mjs');
265
+ expect(result.ok).toBe(false);
266
+ if (result.ok) return;
267
+ expect(result.status).toBe(404);
268
+ });
269
+ });
270
+
271
+ describe('getSourceFile: caching', () => {
272
+ it('invalidates the parse cache when the file mtime changes', async () => {
273
+ write('mut.mjs', ['export const FIRST = 1;']);
274
+ utimesSync(join(root, 'mut.mjs'), 1_000, 1_000);
275
+ const before = await payloadOf('mut.mjs');
276
+ expect(before.content).toContain('FIRST');
277
+ expect(before.symbols.declarations.map((d) => d.name)).toContain('FIRST');
278
+
279
+ write('mut.mjs', ['export const SECOND = 2;']);
280
+ utimesSync(join(root, 'mut.mjs'), 2_000, 2_000);
281
+ const after = await payloadOf('mut.mjs');
282
+ expect(after.content).toContain('SECOND');
283
+ expect(after.symbols.declarations.map((d) => d.name)).toContain('SECOND');
284
+ expect(after.content).not.toContain('FIRST');
285
+ });
286
+ });
287
+
288
+ describe('getSourceFile: typescript unavailable', () => {
289
+ it('returns resolver: unavailable with content but empty symbols when the ts import fails', async () => {
290
+ const result = await getSourceFile(root, 'nodes.mjs', {
291
+ tsLoader: async () => {
292
+ throw new Error('typescript not installed');
293
+ },
294
+ });
295
+ expect(result.ok).toBe(true);
296
+ if (!result.ok) return;
297
+ expect(result.payload.resolver).toBe('unavailable');
298
+ expect(result.payload.content).toContain('TAX_RATE');
299
+ expect(result.payload.symbols.declarations).toEqual([]);
300
+ expect(result.payload.symbols.imports).toEqual([]);
301
+ });
302
+ });
303
+
304
+ describe('regression: DeriveShipmentActuals → deriveTrackingActual (thin-adapter consumer shape)', () => {
305
+ let regRoot: string;
306
+
307
+ beforeAll(async () => {
308
+ regRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sourcenav-regression-')));
309
+ const w = (rel: string, lines: string[]): void =>
310
+ writeFileSync(join(regRoot, rel), lines.join('\n') + '\n');
311
+ w('shipment-logic.mjs', [
312
+ 'function nestedHelper(shipment) {', // 1
313
+ ' return (shipment.weight ?? 0) * 2;', // 2
314
+ '}', // 3
315
+ '', // 4
316
+ 'export function deriveTrackingActual(shipment) {', // 5
317
+ ' return nestedHelper(shipment);', // 6
318
+ '}', // 7
319
+ ]);
320
+ w('nodes.mjs', [
321
+ "import { deriveTrackingActual } from './shipment-logic.mjs';", // 1
322
+ '', // 2
323
+ 'export function registerNodes(registry) {', // 3
324
+ ' registry.register(', // 4
325
+ " { type: 'DeriveShipmentActuals', label: 'Derive Shipment Actuals' },", // 5
326
+ ' async (ctx) => ({ actual: deriveTrackingActual(ctx.input) }),', // 6
327
+ ' );', // 7
328
+ '}', // 8
329
+ ]);
330
+ });
331
+
332
+ afterAll(() => {
333
+ if (regRoot) rmSync(regRoot, { recursive: true, force: true });
334
+ });
335
+
336
+ it('nodesPayload carries the node sourceRef, and /source-file navigates to the helper', async () => {
337
+ const mod = (await import(pathToFileURL(join(regRoot, 'nodes.mjs')).href)) as {
338
+ registerNodes: (r: NodeRegistry) => void;
339
+ };
340
+ const { validation } = buildRegistries({
341
+ root: regRoot,
342
+ flowsDir: join(regRoot, 'emberflow', 'flows'),
343
+ language: 'javascript',
344
+ registerNodes: mod.registerNodes,
345
+ });
346
+
347
+ // 1. The registration was captured and lands in the payload repo-relative.
348
+ // (Exact line accuracy under the production loader is asserted by
349
+ // registry.sourceRef.test.ts and sourceNavRoute.test.ts — vitest's
350
+ // vite-node transform shifts lines of in-process dynamic imports.)
351
+ const payload = nodesPayload(validation, regRoot);
352
+ const node = payload.nodes.find((n) => n.type === 'DeriveShipmentActuals');
353
+ expect(node).toBeDefined();
354
+ expect(node!.sourceRef!.file).toBe('nodes.mjs');
355
+ expect(typeof node!.sourceRef!.line).toBe('number');
356
+ expect(node!.builtin).toBeUndefined();
357
+
358
+ // Built-ins registered from the Emberflow package are outside regRoot.
359
+ const builtinNode = payload.nodes.find((n) => n.type === 'ValidateCredentials');
360
+ expect(builtinNode).toBeDefined();
361
+ expect(builtinNode!.builtin).toBe(true);
362
+ expect(builtinNode!.sourceRef).toBeUndefined();
363
+
364
+ // 2. /source-file on the handler module resolves the imported helper.
365
+ const handlerFile = await getSourceFile(regRoot, 'nodes.mjs');
366
+ expect(handlerFile.ok).toBe(true);
367
+ if (!handlerFile.ok) return;
368
+ const imp = handlerFile.payload.symbols.imports.find((i) => i.local === 'deriveTrackingActual');
369
+ expect(imp!.resolution).toEqual({ kind: 'project', path: 'shipment-logic.mjs', line: 5 });
370
+
371
+ // 3. /source-file on the helper module exposes its declaration lines.
372
+ const logicFile = await getSourceFile(regRoot, 'shipment-logic.mjs');
373
+ expect(logicFile.ok).toBe(true);
374
+ if (!logicFile.ok) return;
375
+ const derive = logicFile.payload.symbols.declarations.find(
376
+ (d) => d.name === 'deriveTrackingActual',
377
+ );
378
+ expect(derive).toMatchObject({ kind: 'fn', line: 5, endLine: 7, exported: true });
379
+ const nested = logicFile.payload.symbols.declarations.find((d) => d.name === 'nestedHelper');
380
+ expect(nested).toMatchObject({ kind: 'fn', line: 1, exported: false });
381
+ });
382
+ });