@xdelivered/emberflow 0.2.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 (116) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -0,0 +1,644 @@
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 type * as TS from 'typescript';
6
+ import { isPathWithin } from './pathSafety';
7
+
8
+ /**
9
+ * Source navigation for the studio's node-implementation view: serve a whole
10
+ * project file plus a flat symbol table (top-level declarations, imports with
11
+ * resolutions, re-exports) so the client can render identifiers as links and
12
+ * navigate file-by-file. One flat parse per file, mtime-cached; recursion
13
+ * happens client-side by fetching the next file — except re-export chains,
14
+ * which the server follows (bounded, cycle-safe) so an import through an
15
+ * index module resolves straight to the declaring file.
16
+ *
17
+ * TypeScript is loaded lazily on first use (`await import('typescript')`);
18
+ * when that fails the file is still served with `resolver: 'unavailable'`
19
+ * and empty symbols, and the studio shows unresolved-with-reason.
20
+ */
21
+
22
+ export type Resolution =
23
+ | { kind: 'project'; path: string; line?: number }
24
+ | { kind: 'external'; package: string }
25
+ | { kind: 'builtin' }
26
+ | { kind: 'unresolved'; reason: string };
27
+
28
+ export interface SourceDeclaration {
29
+ name: string;
30
+ kind: 'fn' | 'const' | 'class' | 'other';
31
+ line: number;
32
+ endLine: number;
33
+ exported: boolean;
34
+ }
35
+
36
+ export interface SourceImport {
37
+ /** Imported name in the source module ('default', '*' for namespace, 'import()' for dynamic). */
38
+ name: string;
39
+ /** Local binding name in the served file ('' when there is none). */
40
+ local: string;
41
+ /** The specifier as written. */
42
+ from: string;
43
+ resolution: Resolution;
44
+ }
45
+
46
+ export interface SourceReexport {
47
+ name: string;
48
+ from: string;
49
+ resolution: Resolution;
50
+ }
51
+
52
+ export interface SourceFilePayload {
53
+ path: string;
54
+ content: string;
55
+ language: 'ts' | 'js';
56
+ /** Present (as 'unavailable') only when the typescript module could not be loaded. */
57
+ resolver?: 'unavailable';
58
+ symbols: {
59
+ declarations: SourceDeclaration[];
60
+ imports: SourceImport[];
61
+ reexports: SourceReexport[];
62
+ };
63
+ }
64
+
65
+ export type SourceFileResult =
66
+ | { ok: true; payload: SourceFilePayload }
67
+ | { ok: false; status: 400 | 404; error: string };
68
+
69
+ export interface SourceNavOptions {
70
+ /** Test seam: overrides the lazy `import('typescript')` for this call only. */
71
+ tsLoader?: () => Promise<TsModule>;
72
+ }
73
+
74
+ type TsModule = typeof TS;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Lazy typescript module
78
+
79
+ let tsModulePromise: Promise<TsModule> | undefined;
80
+
81
+ async function loadTs(loader?: () => Promise<TsModule>): Promise<TsModule | undefined> {
82
+ if (loader) {
83
+ try {
84
+ return await loader();
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ if (!tsModulePromise) {
90
+ tsModulePromise = import('typescript').then(
91
+ (m) => ((m as { default?: TsModule }).default ?? m) as TsModule,
92
+ );
93
+ }
94
+ try {
95
+ return await tsModulePromise;
96
+ } catch {
97
+ tsModulePromise = undefined; // allow a later retry (e.g. install completed)
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Path guards
104
+
105
+ const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
106
+
107
+ function languageOf(file: string): 'ts' | 'js' {
108
+ return TS_EXTS.has(extname(file)) ? 'ts' : 'js';
109
+ }
110
+
111
+ /** Deny-list beyond isPathWithin: node_modules anywhere, secret basenames. */
112
+ function isRequestPathAllowed(projectRoot: string, rel: string): boolean {
113
+ if (!isPathWithin(projectRoot, rel)) return false;
114
+ const segments = rel.split('/');
115
+ if (segments.includes('node_modules')) return false;
116
+ const base = (segments[segments.length - 1] ?? '').toLowerCase();
117
+ if (base.startsWith('.env')) return false;
118
+ if (base === 'emberflow.secrets.json') return false;
119
+ if (base.endsWith('.pem') || base.endsWith('.key')) return false;
120
+ return true;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Parse cache (mtime-keyed, per absolute path)
125
+
126
+ interface RawImport {
127
+ name: string;
128
+ local: string;
129
+ from: string;
130
+ }
131
+
132
+ interface RawDynamicImport {
133
+ /** Specifier when it was a string literal; undefined for computed ones. */
134
+ from?: string;
135
+ }
136
+
137
+ interface RawReexport {
138
+ /** Exported name; '*' for `export * from`. */
139
+ exported: string;
140
+ /** Name in the source module (propertyName), when this is a named re-export. */
141
+ source?: string;
142
+ from: string;
143
+ }
144
+
145
+ interface ParsedFile {
146
+ content: string;
147
+ language: 'ts' | 'js';
148
+ declarations: SourceDeclaration[];
149
+ imports: RawImport[];
150
+ dynamicImports: RawDynamicImport[];
151
+ reexports: RawReexport[];
152
+ }
153
+
154
+ const parseCache = new Map<string, { mtimeMs: number; parsed: ParsedFile }>();
155
+
156
+ /** Clears all sourceNav caches (parse, tsconfig, ts module) — tests only. */
157
+ export function resetSourceNavCaches(): void {
158
+ parseCache.clear();
159
+ tsconfigCache.clear();
160
+ tsModulePromise = undefined;
161
+ }
162
+
163
+ function scriptKindFor(ts: TsModule, file: string): TS.ScriptKind {
164
+ switch (extname(file)) {
165
+ case '.ts':
166
+ case '.mts':
167
+ case '.cts':
168
+ return ts.ScriptKind.TS;
169
+ case '.tsx':
170
+ return ts.ScriptKind.TSX;
171
+ case '.jsx':
172
+ return ts.ScriptKind.JSX;
173
+ default:
174
+ return ts.ScriptKind.JS;
175
+ }
176
+ }
177
+
178
+ function parseFile(ts: TsModule, absPath: string, content: string): ParsedFile {
179
+ const sf = ts.createSourceFile(
180
+ absPath,
181
+ content,
182
+ ts.ScriptTarget.Latest,
183
+ /* setParentNodes */ true,
184
+ scriptKindFor(ts, absPath),
185
+ );
186
+ const lineOf = (pos: number): number => sf.getLineAndCharacterOfPosition(pos).line + 1;
187
+ const hasExportModifier = (node: TS.Node): boolean => {
188
+ const mods = (node as { modifiers?: readonly TS.Node[] }).modifiers;
189
+ return !!mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
190
+ };
191
+
192
+ const declarations: SourceDeclaration[] = [];
193
+ const imports: RawImport[] = [];
194
+ const reexports: RawReexport[] = [];
195
+ const dynamicImports: RawDynamicImport[] = [];
196
+ /** Names exported post-hoc via a specifier-less `export { x }`. */
197
+ const localExports = new Set<string>();
198
+
199
+ for (const stmt of sf.statements) {
200
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) {
201
+ declarations.push({
202
+ name: stmt.name.text,
203
+ kind: 'fn',
204
+ line: lineOf(stmt.getStart(sf)),
205
+ endLine: lineOf(stmt.end),
206
+ exported: hasExportModifier(stmt),
207
+ });
208
+ } else if (ts.isClassDeclaration(stmt) && stmt.name) {
209
+ declarations.push({
210
+ name: stmt.name.text,
211
+ kind: 'class',
212
+ line: lineOf(stmt.getStart(sf)),
213
+ endLine: lineOf(stmt.end),
214
+ exported: hasExportModifier(stmt),
215
+ });
216
+ } else if (ts.isVariableStatement(stmt)) {
217
+ for (const decl of stmt.declarationList.declarations) {
218
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
219
+ declarations.push({
220
+ name: decl.name.text,
221
+ kind: 'const',
222
+ line: lineOf(decl.getStart(sf)),
223
+ endLine: lineOf(stmt.end),
224
+ exported: hasExportModifier(stmt),
225
+ });
226
+ }
227
+ }
228
+ } else if (
229
+ ts.isInterfaceDeclaration(stmt) ||
230
+ ts.isTypeAliasDeclaration(stmt) ||
231
+ ts.isEnumDeclaration(stmt)
232
+ ) {
233
+ declarations.push({
234
+ name: stmt.name.text,
235
+ kind: 'other',
236
+ line: lineOf(stmt.getStart(sf)),
237
+ endLine: lineOf(stmt.end),
238
+ exported: hasExportModifier(stmt),
239
+ });
240
+ } else if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
241
+ const from = stmt.moduleSpecifier.text;
242
+ const clause = stmt.importClause;
243
+ if (!clause) {
244
+ imports.push({ name: '*', local: '', from }); // side-effect import
245
+ continue;
246
+ }
247
+ if (clause.name) imports.push({ name: 'default', local: clause.name.text, from });
248
+ const bindings = clause.namedBindings;
249
+ if (bindings) {
250
+ if (ts.isNamespaceImport(bindings)) {
251
+ imports.push({ name: '*', local: bindings.name.text, from });
252
+ } else {
253
+ for (const el of bindings.elements) {
254
+ imports.push({ name: (el.propertyName ?? el.name).text, local: el.name.text, from });
255
+ }
256
+ }
257
+ }
258
+ } else if (ts.isExportDeclaration(stmt)) {
259
+ const spec = stmt.moduleSpecifier;
260
+ if (spec && ts.isStringLiteral(spec)) {
261
+ const from = spec.text;
262
+ if (!stmt.exportClause) {
263
+ reexports.push({ exported: '*', from });
264
+ } else if (ts.isNamespaceExport(stmt.exportClause)) {
265
+ reexports.push({ exported: stmt.exportClause.name.text, source: '*', from });
266
+ } else {
267
+ for (const el of stmt.exportClause.elements) {
268
+ reexports.push({
269
+ exported: el.name.text,
270
+ source: (el.propertyName ?? el.name).text,
271
+ from,
272
+ });
273
+ }
274
+ }
275
+ } else if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
276
+ for (const el of stmt.exportClause.elements) {
277
+ localExports.add((el.propertyName ?? el.name).text);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ // `export { x }` after the declaration marks it exported.
284
+ for (const d of declarations) {
285
+ if (!d.exported && localExports.has(d.name)) d.exported = true;
286
+ }
287
+
288
+ // Dynamic imports live anywhere in the tree.
289
+ const visit = (node: TS.Node): void => {
290
+ if (
291
+ ts.isCallExpression(node) &&
292
+ node.expression.kind === ts.SyntaxKind.ImportKeyword
293
+ ) {
294
+ const arg = node.arguments[0];
295
+ dynamicImports.push(
296
+ arg && ts.isStringLiteral(arg) ? { from: arg.text } : {},
297
+ );
298
+ }
299
+ ts.forEachChild(node, visit);
300
+ };
301
+ visit(sf);
302
+
303
+ return { content, language: languageOf(absPath), declarations, imports, dynamicImports, reexports };
304
+ }
305
+
306
+ /** Parse (or reuse the cached parse of) an absolute path. Throws when unreadable. */
307
+ function getParsed(ts: TsModule, absPath: string): ParsedFile {
308
+ const stat = statSync(absPath);
309
+ const cached = parseCache.get(absPath);
310
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.parsed;
311
+ const content = readFileSync(absPath, 'utf8');
312
+ const parsed = parseFile(ts, absPath, content);
313
+ parseCache.set(absPath, { mtimeMs: stat.mtimeMs, parsed });
314
+ return parsed;
315
+ }
316
+
317
+ function getParsedSafe(ts: TsModule, absPath: string): ParsedFile | undefined {
318
+ try {
319
+ return getParsed(ts, absPath);
320
+ } catch {
321
+ return undefined;
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Specifier resolution
327
+
328
+ const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];
329
+ /** TS-style specifier mapping: an emitted-extension specifier may point at TS source. */
330
+ const JS_TO_TS: Record<string, string[]> = {
331
+ '.js': ['.ts', '.tsx'],
332
+ '.mjs': ['.mts'],
333
+ '.cjs': ['.cts'],
334
+ };
335
+
336
+ function isFile(p: string): boolean {
337
+ try {
338
+ return statSync(p).isFile();
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ /** Extension ladder: exact → +ext → TS .js→.ts mapping → /index.* */
345
+ function resolveWithLadder(base: string): string | undefined {
346
+ if (isFile(base)) return base;
347
+ for (const ext of RESOLVE_EXTS) {
348
+ if (isFile(base + ext)) return base + ext;
349
+ }
350
+ for (const [jsExt, tsExts] of Object.entries(JS_TO_TS)) {
351
+ if (base.endsWith(jsExt)) {
352
+ const stem = base.slice(0, -jsExt.length);
353
+ for (const tsExt of tsExts) {
354
+ if (isFile(stem + tsExt)) return stem + tsExt;
355
+ }
356
+ }
357
+ }
358
+ for (const ext of RESOLVE_EXTS) {
359
+ const idx = join(base, 'index' + ext);
360
+ if (isFile(idx)) return idx;
361
+ }
362
+ return undefined;
363
+ }
364
+
365
+ // tsconfig `paths` support (single-star patterns), mtime-cached per root.
366
+ interface TsconfigPaths {
367
+ base: string;
368
+ entries: Array<{ prefix: string; suffix: string; exact?: string; targets: string[] }>;
369
+ }
370
+
371
+ const tsconfigCache = new Map<string, { mtimeMs: number; paths: TsconfigPaths | undefined }>();
372
+
373
+ function tsconfigPathsFor(ts: TsModule, projectRoot: string): TsconfigPaths | undefined {
374
+ const cfgPath = join(projectRoot, 'tsconfig.json');
375
+ let mtimeMs: number;
376
+ try {
377
+ mtimeMs = statSync(cfgPath).mtimeMs;
378
+ } catch {
379
+ return undefined;
380
+ }
381
+ const cached = tsconfigCache.get(cfgPath);
382
+ if (cached && cached.mtimeMs === mtimeMs) return cached.paths;
383
+
384
+ let paths: TsconfigPaths | undefined;
385
+ try {
386
+ // ts.readConfigFile parses leniently — tsconfig.json is JSONC.
387
+ const { config } = ts.readConfigFile(cfgPath, (p) => readFileSync(p, 'utf8'));
388
+ const options = (config as { compilerOptions?: { baseUrl?: string; paths?: Record<string, unknown> } })
389
+ ?.compilerOptions;
390
+ const rawPaths = options?.paths;
391
+ if (rawPaths && typeof rawPaths === 'object') {
392
+ const entries: TsconfigPaths['entries'] = [];
393
+ for (const [pattern, targetsRaw] of Object.entries(rawPaths)) {
394
+ if (!Array.isArray(targetsRaw)) continue;
395
+ const targets = targetsRaw.filter((t): t is string => typeof t === 'string');
396
+ const stars = pattern.split('*').length - 1;
397
+ if (stars === 0) {
398
+ entries.push({ prefix: '', suffix: '', exact: pattern, targets });
399
+ } else if (stars === 1) {
400
+ const [prefix = '', suffix = ''] = pattern.split('*');
401
+ entries.push({ prefix, suffix, targets });
402
+ }
403
+ // multi-star patterns are invalid tsconfig anyway — ignored
404
+ }
405
+ // Longest prefix wins, mirroring TS's matching order.
406
+ entries.sort((a, b) => (b.exact ?? b.prefix).length - (a.exact ?? a.prefix).length);
407
+ paths = { base: resolve(projectRoot, options?.baseUrl ?? '.'), entries };
408
+ }
409
+ } catch {
410
+ paths = undefined;
411
+ }
412
+ tsconfigCache.set(cfgPath, { mtimeMs, paths });
413
+ return paths;
414
+ }
415
+
416
+ function resolveViaTsconfigPaths(ts: TsModule, projectRoot: string, spec: string): string | undefined {
417
+ const cfg = tsconfigPathsFor(ts, projectRoot);
418
+ if (!cfg) return undefined;
419
+ for (const entry of cfg.entries) {
420
+ let substituted: string[] | undefined;
421
+ if (entry.exact !== undefined) {
422
+ if (spec === entry.exact) substituted = entry.targets;
423
+ } else if (
424
+ spec.startsWith(entry.prefix) &&
425
+ spec.endsWith(entry.suffix) &&
426
+ spec.length >= entry.prefix.length + entry.suffix.length
427
+ ) {
428
+ const middle = spec.slice(entry.prefix.length, spec.length - entry.suffix.length);
429
+ substituted = entry.targets.map((t) => t.replace('*', middle));
430
+ }
431
+ if (!substituted) continue;
432
+ for (const target of substituted) {
433
+ const found = resolveWithLadder(resolve(cfg.base, target));
434
+ if (found) return found;
435
+ }
436
+ }
437
+ return undefined;
438
+ }
439
+
440
+ const NODE_BUILTINS = new Set(builtinModules);
441
+
442
+ /** Repo-relative POSIX path when `abs` sits inside the root (and outside node_modules). */
443
+ function projectRelative(projectRoot: string, abs: string): string | undefined {
444
+ const rel = relative(projectRoot, abs);
445
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return undefined;
446
+ if (rel.split('/').includes('node_modules')) return undefined;
447
+ return rel;
448
+ }
449
+
450
+ /**
451
+ * Resolve a specifier from `importerAbs`. Project results carry the ABSOLUTE
452
+ * target path in `absPath` (for chain following); callers convert to
453
+ * repo-relative for the response.
454
+ */
455
+ function resolveSpecifier(
456
+ ts: TsModule,
457
+ projectRoot: string,
458
+ importerAbs: string,
459
+ spec: string,
460
+ ): Resolution & { absPath?: string } {
461
+ if (spec.startsWith('./') || spec.startsWith('../')) {
462
+ const target = resolveWithLadder(resolve(dirname(importerAbs), spec));
463
+ if (!target) return { kind: 'unresolved', reason: `cannot resolve relative import '${spec}'` };
464
+ const rel = projectRelative(projectRoot, target);
465
+ if (!rel) return { kind: 'unresolved', reason: 'resolves outside the project root' };
466
+ return { kind: 'project', path: rel, absPath: target };
467
+ }
468
+ if (isAbsolute(spec)) return { kind: 'unresolved', reason: 'absolute-path specifier' };
469
+ if (spec.startsWith('node:')) return { kind: 'builtin' };
470
+ const viaPaths = resolveViaTsconfigPaths(ts, projectRoot, spec);
471
+ if (viaPaths) {
472
+ const rel = projectRelative(projectRoot, viaPaths);
473
+ if (!rel) return { kind: 'unresolved', reason: 'resolves outside the project root' };
474
+ return { kind: 'project', path: rel, absPath: viaPaths };
475
+ }
476
+ if (NODE_BUILTINS.has(spec)) return { kind: 'builtin' };
477
+ const parts = spec.split('/');
478
+ const pkg = spec.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0]!;
479
+ return { kind: 'external', package: pkg };
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Re-export chain following
484
+
485
+ const REEXPORT_DEPTH_CAP = 8;
486
+
487
+ /**
488
+ * Find the file+line where `exportName` is actually DECLARED, starting at
489
+ * `absFile` and following named/star re-exports. Depth-capped, cycle-safe.
490
+ */
491
+ function findExportedDeclaration(
492
+ ts: TsModule,
493
+ projectRoot: string,
494
+ absFile: string,
495
+ exportName: string,
496
+ depth = 0,
497
+ seen = new Set<string>(),
498
+ ): { absFile: string; line: number } | undefined {
499
+ if (depth > REEXPORT_DEPTH_CAP) return undefined;
500
+ const key = `${absFile}::${exportName}`;
501
+ if (seen.has(key)) return undefined;
502
+ seen.add(key);
503
+
504
+ const parsed = getParsedSafe(ts, absFile);
505
+ if (!parsed) return undefined;
506
+
507
+ const decl =
508
+ parsed.declarations.find((d) => d.exported && d.name === exportName) ??
509
+ parsed.declarations.find((d) => d.name === exportName);
510
+ if (decl) return { absFile, line: decl.line };
511
+
512
+ const named = parsed.reexports.find((r) => r.exported === exportName && r.source && r.source !== '*');
513
+ if (named) {
514
+ const res = resolveSpecifier(ts, projectRoot, absFile, named.from);
515
+ if (res.kind === 'project' && res.absPath) {
516
+ return findExportedDeclaration(ts, projectRoot, res.absPath, named.source!, depth + 1, seen);
517
+ }
518
+ return undefined;
519
+ }
520
+
521
+ for (const star of parsed.reexports.filter((r) => r.exported === '*')) {
522
+ const res = resolveSpecifier(ts, projectRoot, absFile, star.from);
523
+ if (res.kind !== 'project' || !res.absPath) continue;
524
+ const found = findExportedDeclaration(ts, projectRoot, res.absPath, exportName, depth + 1, seen);
525
+ if (found) return found;
526
+ }
527
+ return undefined;
528
+ }
529
+
530
+ /** Resolution for an import/re-export entry, chased to the declaring file when possible. */
531
+ function resolveEntry(
532
+ ts: TsModule,
533
+ projectRoot: string,
534
+ importerAbs: string,
535
+ spec: string,
536
+ symbolName: string | undefined,
537
+ ): Resolution {
538
+ const res = resolveSpecifier(ts, projectRoot, importerAbs, spec);
539
+ if (res.kind !== 'project' || !res.absPath) {
540
+ const { absPath: _drop, ...pub } = res;
541
+ return pub;
542
+ }
543
+ if (symbolName && symbolName !== '*' && symbolName !== 'default') {
544
+ const found = findExportedDeclaration(ts, projectRoot, res.absPath, symbolName);
545
+ if (found) {
546
+ const rel = projectRelative(projectRoot, found.absFile);
547
+ if (rel) return { kind: 'project', path: rel, line: found.line };
548
+ }
549
+ }
550
+ return { kind: 'project', path: res.path };
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Entry point
555
+
556
+ export async function getSourceFile(
557
+ projectRoot: string,
558
+ relPath: string,
559
+ opts?: SourceNavOptions,
560
+ ): Promise<SourceFileResult> {
561
+ const root = resolve(projectRoot);
562
+ // Generic 400: never echo the requested path back to the client.
563
+ if (typeof relPath !== 'string' || !isRequestPathAllowed(root, relPath)) {
564
+ return { ok: false, status: 400, error: 'Invalid or denied path' };
565
+ }
566
+ const abs = resolve(root, relPath);
567
+ let stat;
568
+ try {
569
+ stat = await fsp.stat(abs);
570
+ } catch {
571
+ return { ok: false, status: 404, error: 'File not found' };
572
+ }
573
+ if (!stat.isFile()) return { ok: false, status: 404, error: 'File not found' };
574
+
575
+ const responsePath = relative(root, abs);
576
+
577
+ const ts = await loadTs(opts?.tsLoader);
578
+ if (!ts) {
579
+ const content = await fsp.readFile(abs, 'utf8');
580
+ return {
581
+ ok: true,
582
+ payload: {
583
+ path: responsePath,
584
+ content,
585
+ language: languageOf(abs),
586
+ resolver: 'unavailable',
587
+ symbols: { declarations: [], imports: [], reexports: [] },
588
+ },
589
+ };
590
+ }
591
+
592
+ let parsed: ParsedFile;
593
+ try {
594
+ parsed = getParsed(ts, abs);
595
+ } catch {
596
+ return { ok: false, status: 404, error: 'File not found' };
597
+ }
598
+
599
+ const imports: SourceImport[] = parsed.imports.map((raw) => ({
600
+ name: raw.name,
601
+ local: raw.local,
602
+ from: raw.from,
603
+ resolution: resolveEntry(ts, root, abs, raw.from, raw.name),
604
+ }));
605
+ for (const dyn of parsed.dynamicImports) {
606
+ if (dyn.from !== undefined) {
607
+ imports.push({
608
+ name: 'import()',
609
+ local: '',
610
+ from: dyn.from,
611
+ resolution: resolveEntry(ts, root, abs, dyn.from, undefined),
612
+ });
613
+ } else {
614
+ imports.push({
615
+ name: 'import()',
616
+ local: '',
617
+ from: '(computed)',
618
+ resolution: { kind: 'unresolved', reason: 'dynamic import with a computed specifier' },
619
+ });
620
+ }
621
+ }
622
+
623
+ const reexports: SourceReexport[] = parsed.reexports.map((raw) => ({
624
+ name: raw.exported,
625
+ from: raw.from,
626
+ resolution: resolveEntry(
627
+ ts,
628
+ root,
629
+ abs,
630
+ raw.from,
631
+ raw.source && raw.source !== '*' ? raw.source : undefined,
632
+ ),
633
+ }));
634
+
635
+ return {
636
+ ok: true,
637
+ payload: {
638
+ path: responsePath,
639
+ content: parsed.content,
640
+ language: parsed.language,
641
+ symbols: { declarations: parsed.declarations, imports, reexports },
642
+ },
643
+ };
644
+ }