@jskit-ai/architecture-inspector 0.1.0 → 0.1.2

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.
@@ -0,0 +1,343 @@
1
+ import ts from "typescript";
2
+ import { bindFeatures } from "./features.js";
3
+ import { collectFileFunctions, isFunctionLike, point } from "./functions.js";
4
+ import { createEvaluator } from "./evaluate.js";
5
+ import { loadCallProgram } from "./program.js";
6
+ import { createResolver } from "./resolve.js";
7
+ import { createSymbols } from "./symbols.js";
8
+ import { collectTransportSites, matchSite, routeTable, sitePatterns } from "./transports.js";
9
+
10
+ const MAX_CALLS_PER_FILE = 2000;
11
+ const MAX_CALLEE_LENGTH = 120;
12
+
13
+ function calleeText(call) {
14
+ return call.expression.getText().replace(/\s+/gu, " ").slice(0, MAX_CALLEE_LENGTH);
15
+ }
16
+
17
+ // The callee's own name token, so calls that start together (`a.b().c()`)
18
+ // keep distinct identities.
19
+ function callIdentityPosition(call) {
20
+ const callee = call.expression;
21
+ if (ts.isPropertyAccessExpression(callee)) {
22
+ return callee.name.getStart();
23
+ }
24
+ if (ts.isElementAccessExpression(callee)) {
25
+ return callee.argumentExpression.getStart();
26
+ }
27
+ return callee.getStart();
28
+ }
29
+
30
+ function rangeOf(sourceFile, node) {
31
+ return { start: point(sourceFile, node.getStart(sourceFile)), end: point(sourceFile, node.end) };
32
+ }
33
+
34
+ // Function-level facts for the declared project scripts: per-file functions,
35
+ // resolved calls and call sites, transport operation uses, named action entry
36
+ // points and entry operation controllers. `reuse` holds
37
+ // earlier call records of files whose own and directly imported sources are
38
+ // unchanged; they are kept unless a target no longer exists or the sites
39
+ // other files' arguments resolve in them have changed.
40
+ function inspectCalls({
41
+ projectRoot,
42
+ sources,
43
+ hashes,
44
+ resolveImport,
45
+ entryOperations,
46
+ handlerRanges,
47
+ transports,
48
+ parentIdForFile,
49
+ prefixAliases,
50
+ diagnose,
51
+ reuse = new Map()
52
+ }) {
53
+ const loaded = loadCallProgram({ projectRoot, sources, resolveImport });
54
+ const { checker, sourceFiles, fileOf } = loaded;
55
+ // TypeScript recovers from syntax errors; such a file's functions and calls
56
+ // may be incomplete.
57
+ let complete = loaded.complete;
58
+ for (const [file, sourceFile] of sourceFiles) {
59
+ if (loaded.program.getSyntacticDiagnostics(sourceFile).length) {
60
+ complete = false;
61
+ diagnose("SCRIPT_SYNTAX_RECOVERED", "The script has syntax errors, so its functions and calls may be incomplete.", file);
62
+ }
63
+ }
64
+ const symbols = createSymbols({ checker, fileOf, resolveImport, isLibrary: loaded.isLibrary });
65
+ const evaluator = createEvaluator({ symbols, fileOf });
66
+
67
+ const functionsByFile = new Map();
68
+ const nodeById = new Map();
69
+ const recordByNode = new Map();
70
+ const moduleByFile = new Map();
71
+ const startByNode = new Map();
72
+ const functionNames = new Map();
73
+ const callsByFile = new Map();
74
+ const allCalls = [];
75
+ for (const [file, sourceFile] of sourceFiles) {
76
+ const collected = collectFileFunctions({ file, hash: hashes.get(file), sourceFile, checker });
77
+ functionsByFile.set(file, collected.functions);
78
+ moduleByFile.set(file, collected.module);
79
+ for (const { node, record, startPosition } of collected.nodes) {
80
+ recordByNode.set(node, record);
81
+ nodeById.set(record.id, node);
82
+ startByNode.set(node, startPosition);
83
+ if (record.name && record.kind !== "constructor") {
84
+ functionNames.set(record.name, [...(functionNames.get(record.name) || []), node]);
85
+ }
86
+ }
87
+ const calls = [];
88
+ const visit = (node) => {
89
+ if ((ts.isCallExpression(node) || ts.isNewExpression(node)) && node.expression) {
90
+ calls.push(node);
91
+ }
92
+ ts.forEachChild(node, visit);
93
+ };
94
+ visit(sourceFile);
95
+ callsByFile.set(file, calls);
96
+ allCalls.push(...calls);
97
+ }
98
+
99
+ const features = bindFeatures({ calls: allCalls.filter((call) => ts.isCallExpression(call)), symbols, evaluator });
100
+ const actionsById = new Map();
101
+ const unresolvedActionFiles = new Set();
102
+ for (const action of features.actions) {
103
+ if (!action.actionId) {
104
+ unresolvedActionFiles.add(fileOf(action.object));
105
+ continue;
106
+ }
107
+ const known = actionsById.get(action.actionId) || [];
108
+ if (!known.some((entry) => entry.object === action.object)) {
109
+ actionsById.set(action.actionId, [...known, action]);
110
+ }
111
+ }
112
+ for (const file of unresolvedActionFiles) {
113
+ diagnose("ACTION_ID_UNRESOLVED", "An action definition's id is not a literal, constant or literal-array element.", file);
114
+ }
115
+
116
+ const resolver = createResolver({
117
+ program: loaded.program,
118
+ checker,
119
+ symbols,
120
+ evaluator,
121
+ bindings: features.bindings,
122
+ actionsById,
123
+ fileOf,
124
+ functionNames
125
+ });
126
+ const functionId = (node) => recordByNode.get(node)?.id;
127
+ const functionIds = new Set(nodeById.keys());
128
+ const targetsExist = (entries) => entries.every(({ 1: targets }) => targets.every((target) => functionIds.has(target)));
129
+ const reusable = new Set([...reuse].filter(([file, cached]) => callsByFile.has(file) &&
130
+ targetsExist(cached.sites) && cached.calls.every((record) => record.targets.every((target) => functionIds.has(target))))
131
+ .map(([file]) => file));
132
+ // Each file's checker targets per call position, in call order, so argument
133
+ // flow into closures that run later sees the same call sites whether a file
134
+ // is reused or resolved. A reused file's checker sites depend only on its own
135
+ // and directly imported sources, so they come from the cache.
136
+ const sitesByFile = new Map();
137
+ const candidatesByFile = new Map();
138
+ for (const [file, calls] of callsByFile) {
139
+ if (reusable.has(file)) {
140
+ const cached = reuse.get(file);
141
+ const callAt = new Map(calls.map((call) => [call.getStart(), call]));
142
+ for (const [position, targets] of cached.sites) {
143
+ resolver.indexSite(callAt.get(position), targets.map((target) => nodeById.get(target)));
144
+ }
145
+ sitesByFile.set(file, cached.sites);
146
+ candidatesByFile.set(file, resolver.boundCandidates(cached.boundCandidates.map((position) => callAt.get(position))));
147
+ continue;
148
+ }
149
+ const sites = [];
150
+ resolver.indexCalls(calls).forEach((targets, index) => {
151
+ if (targets.length) {
152
+ sites.push([calls[index].getStart(), targets.map(functionId)]);
153
+ }
154
+ });
155
+ sitesByFile.set(file, sites);
156
+ candidatesByFile.set(file, resolver.boundCandidates(calls));
157
+ }
158
+ // The sites injection and argument flow resolve depend on other files'
159
+ // arguments, so they are resolved on every run, for every file. A site
160
+ // carries a third element when its targets are a guess.
161
+ const candidates = [...candidatesByFile.values()].flat();
162
+ const boundByFile = new Map([...callsByFile.keys()].map((file) => [file, []]));
163
+ resolver.indexBoundCalls(candidates).forEach(({ targets, probable }, index) => {
164
+ const call = candidates[index];
165
+ const file = fileOf(call);
166
+ const ids = targets.map(functionId);
167
+ if (ids.length && ids.every(Boolean) && !sitesByFile.get(file).some(([position]) => position === call.getStart())) {
168
+ boundByFile.get(file).push(probable ? [call.getStart(), ids, 1] : [call.getStart(), ids]);
169
+ }
170
+ });
171
+ // A reused file keeps its records while its bound sites are the ones they
172
+ // were resolved with, and while every file its records reach keeps its own.
173
+ const sameSites = (left, right) => JSON.stringify(left) === JSON.stringify(right);
174
+ const reused = new Set([...reusable].filter((file) => sameSites(reuse.get(file).boundSites, boundByFile.get(file))));
175
+ const changedBound = new Set([...reusable].filter((file) => !reused.has(file)));
176
+ for (let changed = changedBound.size > 0; changed;) {
177
+ changed = false;
178
+ for (const file of reused) {
179
+ const reaches = reuse.get(file).calls.some((record) => record.targets.some((target) => changedBound.has(fileOf(nodeById.get(target)))));
180
+ if (reaches) {
181
+ reused.delete(file);
182
+ changedBound.add(file);
183
+ changed = true;
184
+ }
185
+ }
186
+ }
187
+
188
+ function enclosingFunction(node) {
189
+ const position = node.getStart();
190
+ for (let current = node.parent; current; current = current.parent) {
191
+ if (isFunctionLike(current) && startByNode.get(current) <= position) {
192
+ return recordByNode.get(current);
193
+ }
194
+ }
195
+ return moduleByFile.get(fileOf(node));
196
+ }
197
+
198
+ // What the cache keeps of a file's sites for the next run.
199
+ const fileSites = (file) => ({
200
+ sites: sitesByFile.get(file),
201
+ boundCandidates: candidatesByFile.get(file).map((call) => call.getStart()),
202
+ boundSites: boundByFile.get(file)
203
+ });
204
+ const fileRecords = new Map();
205
+ let truncated = false;
206
+ for (const [file, calls] of callsByFile) {
207
+ if (reused.has(file)) {
208
+ const cached = reuse.get(file);
209
+ fileRecords.set(file, { functions: functionsByFile.get(file), ...fileSites(file), calls: cached.calls, truncated: cached.truncated });
210
+ if (cached.truncated) {
211
+ truncated = true;
212
+ diagnose("CALLS_TRUNCATED", `Only the first ${MAX_CALLS_PER_FILE} calls of this file are recorded.`, file);
213
+ }
214
+ continue;
215
+ }
216
+ const sourceFile = sourceFiles.get(file);
217
+ const records = [];
218
+ let fileTruncated = false;
219
+ const identities = new Set();
220
+ let count = 0;
221
+ for (const call of calls) {
222
+ const from = enclosingFunction(call);
223
+ // The call itself, then what it reaches through the functions it passes.
224
+ const results = from ? [resolver.resolve(call, file), resolver.passedCall(call)].filter(Boolean) : [];
225
+ if (count + results.length > MAX_CALLS_PER_FILE) {
226
+ fileTruncated = true;
227
+ truncated = true;
228
+ diagnose("CALLS_TRUNCATED", `Only the first ${MAX_CALLS_PER_FILE} calls of this file are recorded.`, file);
229
+ break;
230
+ }
231
+ const position = point(sourceFile, callIdentityPosition(call));
232
+ for (const result of results) {
233
+ count += 1;
234
+ let id = `call:jskit:${file}#${position.line}:${position.column}`;
235
+ for (let suffix = 2; identities.has(id); suffix += 1) {
236
+ id = `call:jskit:${file}#${position.line}:${position.column}~${suffix}`;
237
+ }
238
+ identities.add(id);
239
+ records.push({
240
+ id,
241
+ resolution: result.resolution,
242
+ certainty: result.certainty,
243
+ method: result.method,
244
+ fromFunctionId: from.id,
245
+ targets: result.targets.map(functionId),
246
+ callee: calleeText(call),
247
+ evidence: [{ path: file, hash: hashes.get(file), ...rangeOf(sourceFile, call) }]
248
+ });
249
+ }
250
+ }
251
+ fileRecords.set(file, { functions: functionsByFile.get(file), ...fileSites(file), calls: records, truncated: fileTruncated });
252
+ }
253
+
254
+ const routes = routeTable(entryOperations);
255
+ const operationUses = [];
256
+ for (const site of transports ? collectTransportSites({ calls: allCalls, symbols, evaluator, fileOf }) : []) {
257
+ const sourceFile = sourceFiles.get(site.file);
258
+ const from = enclosingFunction(site.call);
259
+ if (!from) {
260
+ continue;
261
+ }
262
+ const position = point(sourceFile, site.call.getStart());
263
+ const baseId = `operation-use:${site.file}#${position.line}:${position.column}`;
264
+ const parentId = parentIdForFile(site.file);
265
+ const evidence = [{ path: site.file, hash: hashes.get(site.file), ...rangeOf(sourceFile, site.call) }];
266
+ const matched = matchSite({ patterns: sitePatterns(site, evaluator), methods: site.methods }, routes, prefixAliases)
267
+ .flatMap(({ method, matches }) => (matches.length ? matches : [{ method, route: null }]).map((match) => ({ method, ...match })));
268
+ const uses = matched.length ? matched : [{ method: null, route: null }];
269
+ uses.forEach((use, index) => {
270
+ operationUses.push({
271
+ id: uses.length === 1 ? baseId : `${baseId}:${use.method || "ANY"}:${index + 1}`,
272
+ ...(parentId ? { parentId } : {}),
273
+ fromFunctionId: from.id,
274
+ resolution: use.route ? "resolved" : "unresolved",
275
+ ...(use.route ? { key: use.route.key, keyCertainty: use.keyCertainty } : {}),
276
+ evidence
277
+ });
278
+ });
279
+ }
280
+
281
+ const entryPoints = [];
282
+ for (const [actionId, definitions] of actionsById) {
283
+ const executes = [...new Set(definitions.flatMap((definition) => definition.execute))];
284
+ const first = definitions[0].object;
285
+ const file = fileOf(first);
286
+ entryPoints.push({
287
+ id: `entry:action:${actionId}`,
288
+ resolution: "resolved",
289
+ kind: "action",
290
+ title: actionId,
291
+ file,
292
+ functionIds: executes.map(functionId).filter(Boolean),
293
+ pageId: null,
294
+ componentId: null,
295
+ operationKey: null,
296
+ actionId,
297
+ evidence: definitions.map((definition) => {
298
+ const owner = fileOf(definition.object);
299
+ return { path: owner, hash: hashes.get(owner), ...rangeOf(sourceFiles.get(owner), definition.object) };
300
+ })
301
+ });
302
+ }
303
+
304
+ function actionController(actionId) {
305
+ const definitions = actionsById.get(actionId) || [];
306
+ const executes = definitions.flatMap((definition) => definition.execute);
307
+ return definitions.length === 1 && executes.length === 1 ? functionId(executes[0]) : null;
308
+ }
309
+ // The function whose source range (start offset .. end offset) is given.
310
+ function functionAt({ file, start, end }) {
311
+ const sourceFile = sourceFiles.get(file);
312
+ let found = null;
313
+ const visit = (node) => {
314
+ if (found || node.end < start || node.pos > end) {
315
+ return;
316
+ }
317
+ if (isFunctionLike(node) && node.getStart(sourceFile) === start && node.end === end) {
318
+ found = recordByNode.get(node);
319
+ return;
320
+ }
321
+ ts.forEachChild(node, visit);
322
+ };
323
+ if (sourceFile) {
324
+ visit(sourceFile);
325
+ }
326
+ return found?.id || null;
327
+ }
328
+ // Each entry operation's controller: its action's execute function, or the
329
+ // inline handler. Only plain records are returned, so the program can be
330
+ // released before the output is written.
331
+ const controllers = new Map();
332
+ for (const entry of entryOperations) {
333
+ const handler = handlerRanges.get(entry);
334
+ const controller = entry.actionId ? actionController(entry.actionId) : handler && functionAt(handler);
335
+ if (controller) {
336
+ controllers.set(entry, controller);
337
+ }
338
+ }
339
+
340
+ return { fileRecords, operationUses, entryPoints, controllers, complete, truncated };
341
+ }
342
+
343
+ export { inspectCalls };
@@ -0,0 +1,143 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { parse as parseSfc } from "@vue/compiler-sfc";
4
+
5
+ const SCRIPT_EXTENSIONS = Object.freeze({
6
+ ".js": ts.Extension.Js,
7
+ ".mjs": ts.Extension.Mjs,
8
+ ".cjs": ts.Extension.Cjs,
9
+ ".jsx": ts.Extension.Jsx,
10
+ ".ts": ts.Extension.Ts,
11
+ ".tsx": ts.Extension.Tsx
12
+ });
13
+ const LIBRARIES = Object.freeze(["lib.es2023.d.ts", "lib.dom.d.ts", "lib.dom.iterable.d.ts"]);
14
+ const COMPILER_OPTIONS = Object.freeze({
15
+ allowJs: true,
16
+ checkJs: false,
17
+ noEmit: true,
18
+ strict: false,
19
+ target: ts.ScriptTarget.ES2022,
20
+ module: ts.ModuleKind.ESNext,
21
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
22
+ jsx: ts.JsxEmit.Preserve,
23
+ skipLibCheck: true,
24
+ skipDefaultLibCheck: true,
25
+ allowArbitraryExtensions: true,
26
+ allowImportingTsExtensions: true,
27
+ maxNodeModuleJsDepth: 0,
28
+ types: [],
29
+ lib: LIBRARIES
30
+ });
31
+
32
+ // A Vue component's script blocks at their original offsets; template and
33
+ // style text become spaces so every position matches the component file.
34
+ function vueScriptText(source, filePath) {
35
+ const { descriptor, errors } = parseSfc(source, { filename: filePath, sourceMap: false });
36
+ if (errors.length) {
37
+ return null;
38
+ }
39
+ const characters = source.replace(/[^\n]/gu, " ").split("");
40
+ let extension = ".js";
41
+ for (const script of [descriptor.script, descriptor.scriptSetup].filter((entry) => entry && !entry.src)) {
42
+ const start = script.loc.start.offset;
43
+ for (let index = 0; index < script.content.length; index += 1) {
44
+ characters[start + index] = script.content[index];
45
+ }
46
+ if (script.lang === "ts" || script.lang === "tsx") {
47
+ extension = `.${script.lang}`;
48
+ } else if (script.lang === "jsx" && extension === ".js") {
49
+ extension = ".jsx";
50
+ }
51
+ }
52
+ return { text: characters.join(""), extension };
53
+ }
54
+
55
+ // One TypeScript program over the declared, hash-verified project
56
+ // scripts only. Imports resolve through the inspector's own resolver
57
+ // (relative, aliases, declared package entry points); everything else, including
58
+ // every node_modules package, stays outside the program. TypeScript's default
59
+ // libraries remain so builtin calls are recognised and dropped.
60
+ function loadCallProgram({ projectRoot, sources, resolveImport }) {
61
+ const virtual = new Map();
62
+ const realToVirtual = new Map();
63
+ let complete = true;
64
+ for (const [file, source] of sources) {
65
+ const absolute = path.join(projectRoot, file);
66
+ let text = source;
67
+ let name = absolute;
68
+ if (file.endsWith(".vue")) {
69
+ const script = vueScriptText(source, file);
70
+ if (!script) {
71
+ complete = false;
72
+ continue;
73
+ }
74
+ text = script.text;
75
+ name = `${absolute}${script.extension}`;
76
+ } else if (!SCRIPT_EXTENSIONS[path.extname(file)]) {
77
+ continue;
78
+ }
79
+ virtual.set(name, { file, text });
80
+ realToVirtual.set(file, name);
81
+ }
82
+
83
+ const libraryDirectory = path.dirname(ts.getDefaultLibFilePath(COMPILER_OPTIONS));
84
+ const isLibraryFile = (name) => path.dirname(name) === libraryDirectory && /^lib\.[\w.]*\.d\.ts$/u.test(path.basename(name));
85
+ const resolutions = new Map();
86
+ function resolveModule(specifier, containing) {
87
+ const from = virtual.get(containing)?.file;
88
+ if (!from) {
89
+ return undefined;
90
+ }
91
+ const key = `${path.posix.dirname(from)}\0${specifier}`;
92
+ if (!resolutions.has(key)) {
93
+ const target = resolveImport(from, specifier);
94
+ const name = target && realToVirtual.get(target);
95
+ const extension = name && SCRIPT_EXTENSIONS[path.extname(name)];
96
+ resolutions.set(key, extension ? { resolvedFileName: name, extension, isExternalLibraryImport: false } : undefined);
97
+ }
98
+ return resolutions.get(key);
99
+ }
100
+
101
+ const host = {
102
+ getSourceFile(name, languageVersion) {
103
+ const text = virtual.get(name)?.text ?? (isLibraryFile(name) ? ts.sys.readFile(name) : undefined);
104
+ return text === undefined ? undefined : ts.createSourceFile(name, text, languageVersion);
105
+ },
106
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
107
+ writeFile: () => {},
108
+ getCurrentDirectory: () => projectRoot,
109
+ getCanonicalFileName: (name) => name,
110
+ useCaseSensitiveFileNames: () => true,
111
+ getNewLine: () => "\n",
112
+ fileExists: (name) => virtual.has(name) || (isLibraryFile(name) && ts.sys.fileExists(name)),
113
+ readFile: (name) => virtual.get(name)?.text ?? (isLibraryFile(name) ? ts.sys.readFile(name) : undefined),
114
+ directoryExists: (name) => name === libraryDirectory || name === projectRoot || name.startsWith(`${projectRoot}${path.sep}`),
115
+ getDirectories: () => [],
116
+ realpath: (name) => name,
117
+ resolveModuleNameLiterals: (literals, containing) =>
118
+ literals.map((literal) => ({ resolvedModule: resolveModule(literal.text, containing) })),
119
+ resolveTypeReferenceDirectiveReferences: (references) =>
120
+ references.map(() => ({ resolvedTypeReferenceDirective: undefined }))
121
+ };
122
+ const program = ts.createProgram({ rootNames: [...virtual.keys()], options: COMPILER_OPTIONS, host });
123
+ const sourceFiles = new Map();
124
+ for (const sourceFile of program.getSourceFiles()) {
125
+ const file = virtual.get(sourceFile.fileName)?.file;
126
+ if (file) {
127
+ sourceFiles.set(file, sourceFile);
128
+ }
129
+ }
130
+ return {
131
+ ts,
132
+ complete,
133
+ program,
134
+ checker: program.getTypeChecker(),
135
+ sourceFiles,
136
+ fileOf: (node) => virtual.get(node.getSourceFile().fileName)?.file ?? null,
137
+ isLibrary: (node) => program.isSourceFileDefaultLibrary(node.getSourceFile()),
138
+ programFiles: program.getSourceFiles().map((sourceFile) =>
139
+ virtual.get(sourceFile.fileName)?.file ?? path.relative(projectRoot, sourceFile.fileName))
140
+ };
141
+ }
142
+
143
+ export { loadCallProgram };