@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,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
|
+
}
|