dsh-model-everything 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/README.en-US.md +63 -19
- package/README.md +373 -345
- package/cordis.patch.yml +9 -0
- package/diagram/package.json +5 -0
- package/lib/client.js +1273 -0
- package/lib/diagram-diff-tool.js +90 -0
- package/lib/diagram-render-tool.js +102 -0
- package/lib/diagram-review-tool.js +99 -0
- package/lib/diagram.js +43 -0
- package/lib/gate.js +14 -12
- package/lib/presentation.js +10 -0
- package/lib/render/budget.js +99 -0
- package/lib/render/collapse.js +163 -0
- package/lib/render/diff.js +138 -0
- package/lib/render/from-mermaid.js +211 -0
- package/lib/render/from-plantuml.js +158 -0
- package/lib/render/index.js +42 -0
- package/lib/render/layout.js +52 -0
- package/lib/render/model.js +17 -0
- package/lib/render/paint.js +194 -0
- package/lib/render/palette.js +127 -0
- package/lib/render/place.js +210 -0
- package/lib/render/svg.js +107 -0
- package/lib/render/viewmodel.js +126 -0
- package/lib/render/write.js +14 -0
- package/lib/scan.js +248 -8
- package/lib/structure-tool.js +2 -2
- package/lib/types/client.d.ts +83 -0
- package/lib/types/diagram-diff-tool.d.ts +8 -0
- package/lib/types/diagram-render-tool.d.ts +8 -0
- package/lib/types/diagram-review-tool.d.ts +8 -0
- package/lib/types/diagram.d.ts +34 -0
- package/lib/types/presentation.d.ts +7 -0
- package/lib/types/render/budget.d.ts +55 -0
- package/lib/types/render/collapse.d.ts +23 -0
- package/lib/types/render/diff.d.ts +44 -0
- package/lib/types/render/from-mermaid.d.ts +27 -0
- package/lib/types/render/from-plantuml.d.ts +27 -0
- package/lib/types/render/index.d.ts +38 -0
- package/lib/types/render/layout.d.ts +22 -0
- package/lib/types/render/model.d.ts +56 -0
- package/lib/types/render/paint.d.ts +94 -0
- package/lib/types/render/palette.d.ts +41 -0
- package/lib/types/render/place.d.ts +39 -0
- package/lib/types/render/svg.d.ts +19 -0
- package/lib/types/render/viewmodel.d.ts +20 -0
- package/lib/types/render/write.d.ts +14 -0
- package/lib/types/scan.d.ts +10 -2
- package/lib/types/structure.d.ts +1 -0
- package/package.json +148 -131
- package/skills/model-everything/SKILL.md +454 -454
- package/skills/model-everything/references/architecture-domains.md +137 -0
- package/skills/model-everything/references/architecture-views.md +119 -0
- package/skills/model-everything-diagram/SKILL.md +82 -0
- package/src/client.ts +307 -0
- package/src/diagram-diff-tool.ts +94 -0
- package/src/diagram-render-tool.ts +108 -0
- package/src/diagram-review-tool.ts +109 -0
- package/src/diagram.ts +61 -0
- package/src/gate.ts +14 -12
- package/src/presentation.ts +12 -0
- package/src/render/budget.ts +135 -0
- package/src/render/collapse.ts +155 -0
- package/src/render/diff.ts +162 -0
- package/src/render/from-mermaid.ts +236 -0
- package/src/render/from-plantuml.ts +178 -0
- package/src/render/index.ts +73 -0
- package/src/render/layout.ts +62 -0
- package/src/render/model.ts +67 -0
- package/src/render/paint.ts +290 -0
- package/src/render/palette.ts +164 -0
- package/src/render/place.ts +234 -0
- package/src/render/svg.ts +127 -0
- package/src/render/viewmodel.ts +136 -0
- package/src/render/write.ts +17 -0
- package/src/scan.ts +807 -564
- package/src/structure-tool.ts +2 -2
- package/src/structure.ts +1 -1
package/src/scan.ts
CHANGED
|
@@ -1,564 +1,807 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build a dependency graph from source files, so the structure review can answer a question about a
|
|
3
|
-
* tree instead of about a diagram someone drew by hand.
|
|
4
|
-
*
|
|
5
|
-
* The review engine already judges a graph; what it never had was a way to get one from a repository.
|
|
6
|
-
* Drawing it first is the expensive step, and it is the step a model does worst: 34 files and 86 edges
|
|
7
|
-
* is more than a reader enumerates reliably, and getting the graph wrong is indistinguishable from
|
|
8
|
-
* getting the code wrong.
|
|
9
|
-
*
|
|
10
|
-
* One language is a pair: an extractor that turns a file into the specifiers it names, and a resolver
|
|
11
|
-
* that turns a specifier into a module id. Adding a language means adding that pair to `LANGUAGES`,
|
|
12
|
-
* not touching the scan.
|
|
13
|
-
*
|
|
14
|
-
* Granularity is per language and is reported, because it is a property of the language rather than of
|
|
15
|
-
* this tool: a C file includes files, while a Go import names a package and a Java import names a
|
|
16
|
-
* package too. A tree that mixes them has both kinds of node, and the edges say which is which.
|
|
17
|
-
*/
|
|
18
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
19
|
-
import { extname, relative, resolve } from 'node:path'
|
|
20
|
-
|
|
21
|
-
import { OptionShapeError } from './option-shape.js'
|
|
22
|
-
|
|
23
|
-
export interface ScannedEdge {
|
|
24
|
-
from: string
|
|
25
|
-
to: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ScannedGraph {
|
|
29
|
-
/** Module ids, posix-style and relative to the scanned root. File ids for file languages, directory ids for package languages. */
|
|
30
|
-
nodes: string[]
|
|
31
|
-
edges: ScannedEdge[]
|
|
32
|
-
/** Files read, and files skipped because they are not source. */
|
|
33
|
-
filesRead: number
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
local.push(
|
|
228
|
-
}
|
|
229
|
-
return { local, external }
|
|
230
|
-
},
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
name: '
|
|
235
|
-
extensions: ['.
|
|
236
|
-
granularity: 'file',
|
|
237
|
-
extract(text) {
|
|
238
|
-
const source = stripCComments(text)
|
|
239
|
-
const local: string[] = []
|
|
240
|
-
const external: string[] = []
|
|
241
|
-
for (const match of source.matchAll(
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
}
|
|
245
|
-
for (const match of source.matchAll(/^\s*use\s+([\w
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Build a dependency graph from source files, so the structure review can answer a question about a
|
|
3
|
+
* tree instead of about a diagram someone drew by hand.
|
|
4
|
+
*
|
|
5
|
+
* The review engine already judges a graph; what it never had was a way to get one from a repository.
|
|
6
|
+
* Drawing it first is the expensive step, and it is the step a model does worst: 34 files and 86 edges
|
|
7
|
+
* is more than a reader enumerates reliably, and getting the graph wrong is indistinguishable from
|
|
8
|
+
* getting the code wrong.
|
|
9
|
+
*
|
|
10
|
+
* One language is a pair: an extractor that turns a file into the specifiers it names, and a resolver
|
|
11
|
+
* that turns a specifier into a module id. Adding a language means adding that pair to `LANGUAGES`,
|
|
12
|
+
* not touching the scan.
|
|
13
|
+
*
|
|
14
|
+
* Granularity is per language and is reported, because it is a property of the language rather than of
|
|
15
|
+
* this tool: a C file includes files, while a Go import names a package and a Java import names a
|
|
16
|
+
* package too. A tree that mixes them has both kinds of node, and the edges say which is which.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
19
|
+
import { extname, relative, resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import { OptionShapeError } from './option-shape.js'
|
|
22
|
+
|
|
23
|
+
export interface ScannedEdge {
|
|
24
|
+
from: string
|
|
25
|
+
to: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ScannedGraph {
|
|
29
|
+
/** Module ids, posix-style and relative to the scanned root. File ids for file languages, directory ids for package languages. */
|
|
30
|
+
nodes: string[]
|
|
31
|
+
edges: ScannedEdge[]
|
|
32
|
+
/** Files read, and files skipped because they are not source. */
|
|
33
|
+
filesRead: number
|
|
34
|
+
/**
|
|
35
|
+
* Specifiers that are project-internal but did not resolve: a relative path that leaves the root or names
|
|
36
|
+
* no file, and an alias-shaped specifier (`@/x`, `~/x`, `#x`) that no `tsconfig`/`jsconfig` declares.
|
|
37
|
+
*/
|
|
38
|
+
unresolved: string[]
|
|
39
|
+
/** Bare specifiers seen, counted once each: external packages and, for a package language, a package
|
|
40
|
+
* path this tree does not contain. */
|
|
41
|
+
packages: string[]
|
|
42
|
+
/** Import calls whose target is computed at run time. No static scan can resolve them, so they are
|
|
43
|
+
* counted rather than guessed. */
|
|
44
|
+
dynamic: number
|
|
45
|
+
/** Which languages were scanned, and at what granularity. */
|
|
46
|
+
languages: { language: string; files: number; granularity: 'file' | 'package' }[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A path alias from `tsconfig.json`: `@/*` -> `src/*`, or a bare `baseUrl`. */
|
|
50
|
+
interface Alias {
|
|
51
|
+
prefix: string
|
|
52
|
+
targets: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `tsconfig.json` allows comments and trailing commas; this reads the subset it needs without a parser. */
|
|
56
|
+
function parseJsonc(text: string): unknown {
|
|
57
|
+
const withoutComments = stripCComments(text)
|
|
58
|
+
const withoutTrailingCommas = withoutComments.replaceAll(/,\s*([}\]])/g, '$1')
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(withoutTrailingCommas)
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The aliases a TypeScript or JavaScript tree declares in its own `tsconfig.json`/`jsconfig.json`.
|
|
68
|
+
*
|
|
69
|
+
* Read rather than asked for: the project already states how it resolves `@/x`, and asking a caller to
|
|
70
|
+
* repeat it is how a scan silently loses half its edges.
|
|
71
|
+
*/
|
|
72
|
+
function readAliases(root: string): { aliases: Alias[]; baseUrl?: string } {
|
|
73
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
74
|
+
let text: string
|
|
75
|
+
try {
|
|
76
|
+
text = readFileSync(resolve(root, name), 'utf8')
|
|
77
|
+
} catch {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const parsed = parseJsonc(text)
|
|
81
|
+
if (typeof parsed !== 'object' || parsed === null) continue
|
|
82
|
+
const options = (parsed as { compilerOptions?: unknown }).compilerOptions
|
|
83
|
+
if (typeof options !== 'object' || options === null) continue
|
|
84
|
+
const compiler = options as { baseUrl?: unknown; paths?: unknown }
|
|
85
|
+
const baseUrl = typeof compiler.baseUrl === 'string' ? compiler.baseUrl.replaceAll(String.fromCharCode(92), '/').replace(/^\.\//, '') : undefined
|
|
86
|
+
const aliases: Alias[] = []
|
|
87
|
+
if (typeof compiler.paths === 'object' && compiler.paths !== null) {
|
|
88
|
+
for (const [pattern, targets] of Object.entries(compiler.paths as Record<string, unknown>)) {
|
|
89
|
+
if (!Array.isArray(targets)) continue
|
|
90
|
+
const cleaned = targets.filter((target): target is string => typeof target === 'string').map((target) => (baseUrl === undefined ? target : normalise([baseUrl, target])))
|
|
91
|
+
if (cleaned.length === 0) continue
|
|
92
|
+
aliases.push({ prefix: pattern.replace(/\*$/, ''), targets: cleaned.map((target) => target.replace(/\*$/, '')) })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { aliases, ...(baseUrl === undefined ? {} : { baseUrl }) }
|
|
96
|
+
}
|
|
97
|
+
return { aliases: [] }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.cache', 'target', 'vendor', '__pycache__', '.venv', 'venv'])
|
|
101
|
+
const LIMITS = { files: 5000, bytes: 2_000_000 }
|
|
102
|
+
|
|
103
|
+
/** Strip comments so a commented-out include is not an edge. */
|
|
104
|
+
const stripCComments = (text: string): string => text.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/(^|[^:])\/\/[^\n]*/g, '$1 ')
|
|
105
|
+
const stripHashComments = (text: string): string => text.replaceAll(/(^|\s)#[^\n]*/g, '$1 ')
|
|
106
|
+
|
|
107
|
+
const dirOf = (id: string): string => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '')
|
|
108
|
+
const normalise = (parts: string[]): string => {
|
|
109
|
+
const stack: string[] = []
|
|
110
|
+
// Each entry may itself be a path: a caller passes a directory and a specifier, and only splitting
|
|
111
|
+
// both is what makes `./x.js` resolve to `x.js` instead of to a segment named `./x.js`.
|
|
112
|
+
for (const entry of parts) {
|
|
113
|
+
for (const part of entry.split('/')) {
|
|
114
|
+
if (part === '' || part === '.') continue
|
|
115
|
+
if (part === '..') stack.pop()
|
|
116
|
+
else stack.push(part)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return stack.join('/')
|
|
120
|
+
}
|
|
121
|
+
const joinPath = (fromId: string, specifier: string): string => normalise([dirOf(fromId), specifier])
|
|
122
|
+
|
|
123
|
+
// ------------------------------------------------------------------------------------------ languages --
|
|
124
|
+
|
|
125
|
+
export type LangGranularity = 'file' | 'package'
|
|
126
|
+
|
|
127
|
+
export interface Language {
|
|
128
|
+
name: string
|
|
129
|
+
extensions: string[]
|
|
130
|
+
granularity: LangGranularity
|
|
131
|
+
/** Specifiers this file names, already separated from external ones. */
|
|
132
|
+
extract(text: string): { local: string[]; external: string[]; dynamic?: number }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const specifiers = (text: string, patterns: RegExp[]): { local: string[]; external: string[] } => {
|
|
136
|
+
const local: string[] = []
|
|
137
|
+
const external: string[] = []
|
|
138
|
+
for (const pattern of patterns) {
|
|
139
|
+
for (const match of text.matchAll(pattern)) {
|
|
140
|
+
const specifier = match[1]
|
|
141
|
+
if (specifier === undefined || specifier === '') continue
|
|
142
|
+
if (specifier.startsWith('.') || specifier.startsWith('/')) local.push(specifier)
|
|
143
|
+
else external.push(specifier)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { local, external }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const JS_LIKE: Language = {
|
|
150
|
+
name: 'typescript',
|
|
151
|
+
extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'],
|
|
152
|
+
granularity: 'file',
|
|
153
|
+
extract(text) {
|
|
154
|
+
const source = stripCComments(text)
|
|
155
|
+
const { local, external } = specifiers(source, [/(?:^|[^.\w])from\s*['"]([^'"]+)['"]/g, /(?:^|[^.\w])import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, /(?:^|[^.\w])require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, /(?:^|[^.\w])import\s*['"]([^'"]+)['"]/g])
|
|
156
|
+
// `@/x`, `~/x` and `#x` are project-internal by convention: a path alias, a subpath import. Counting
|
|
157
|
+
// them as packages made a tree full of aliases look like it had one external dependency and almost no
|
|
158
|
+
// internal edges - the quietest way for a scan to be wrong.
|
|
159
|
+
const aliasShaped = (name: string): boolean => name.startsWith('@/') || name.startsWith('~/') || name.startsWith('#')
|
|
160
|
+
// A call whose target is not a literal cannot be resolved from the source at all. Counting it is the
|
|
161
|
+
// honest move: the graph is missing those edges and the report says how many.
|
|
162
|
+
const dynamic = [...source.matchAll(/(?:^|[^.\w])import\s*\(\s*[^'"\s)]|(?:^|[^.\w])require\s*\(\s*[^'"\s)]/g)].length
|
|
163
|
+
return { local: [...local, ...external.filter(aliasShaped)], external: external.filter((name) => !name.startsWith('node:') && !aliasShaped(name)), dynamic }
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const C_LIKE: Language = {
|
|
168
|
+
name: 'c',
|
|
169
|
+
extensions: ['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx', '.ino'],
|
|
170
|
+
granularity: 'file',
|
|
171
|
+
extract(text) {
|
|
172
|
+
const local: string[] = []
|
|
173
|
+
const external: string[] = []
|
|
174
|
+
for (const match of stripCComments(text).matchAll(/^[ \t]*#[ \t]*include[ \t]*([<"])([^>"]+)[>"]/gm)) {
|
|
175
|
+
const specifier = match[2]
|
|
176
|
+
if (specifier === undefined) continue
|
|
177
|
+
// Quoted includes are the project's own files; angle brackets are the system's, and a system
|
|
178
|
+
// header is not an edge in this tree.
|
|
179
|
+
if (match[1] === '"') local.push(specifier)
|
|
180
|
+
else external.push(specifier)
|
|
181
|
+
}
|
|
182
|
+
return { local, external }
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const PYTHON: Language = {
|
|
187
|
+
name: 'python',
|
|
188
|
+
extensions: ['.py', '.pyi'],
|
|
189
|
+
granularity: 'file',
|
|
190
|
+
extract(text) {
|
|
191
|
+
const source = stripHashComments(text)
|
|
192
|
+
const local: string[] = []
|
|
193
|
+
const external: string[] = []
|
|
194
|
+
for (const match of source.matchAll(/^[ \t]*from[ \t]+(\.*)([\w.]*)[ \t]+import\b/gm)) {
|
|
195
|
+
const dots = match[1] ?? ''
|
|
196
|
+
const module = match[2] ?? ''
|
|
197
|
+
if (dots !== '') local.push(dots + module)
|
|
198
|
+
else if (module !== '') external.push(module)
|
|
199
|
+
}
|
|
200
|
+
for (const match of source.matchAll(/^[ \t]*import[ \t]+([\w.]+)/gm)) {
|
|
201
|
+
const module = match[1]
|
|
202
|
+
if (module !== undefined) external.push(module)
|
|
203
|
+
}
|
|
204
|
+
return { local, external }
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const GO: Language = {
|
|
209
|
+
name: 'go',
|
|
210
|
+
extensions: ['.go'],
|
|
211
|
+
granularity: 'package',
|
|
212
|
+
extract(text) {
|
|
213
|
+
const source = stripCComments(text)
|
|
214
|
+
const local: string[] = []
|
|
215
|
+
const external: string[] = []
|
|
216
|
+
// A block import is the common form; the single form is rare but legal.
|
|
217
|
+
for (const block of source.matchAll(/import\s*\(([\s\S]*?)\)/g)) {
|
|
218
|
+
for (const entry of (block[1] ?? '').matchAll(/"([^"]+)"/g)) {
|
|
219
|
+
const path = entry[1]
|
|
220
|
+
// A module path says nothing about where the package lives relative to this file, so the
|
|
221
|
+
// resolver decides: a path whose tail names a directory here is an edge, the rest are modules.
|
|
222
|
+
if (path !== undefined) local.push(path)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const match of source.matchAll(/^\s*import\s+"([^"]+)"/gm)) {
|
|
226
|
+
const path = match[1]
|
|
227
|
+
if (path !== undefined) local.push(path)
|
|
228
|
+
}
|
|
229
|
+
return { local, external }
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const RUST: Language = {
|
|
234
|
+
name: 'rust',
|
|
235
|
+
extensions: ['.rs'],
|
|
236
|
+
granularity: 'file',
|
|
237
|
+
extract(text) {
|
|
238
|
+
const source = stripCComments(text)
|
|
239
|
+
const local: string[] = []
|
|
240
|
+
const external: string[] = []
|
|
241
|
+
for (const match of source.matchAll(/^\s*(?:pub\s+)?mod\s+([a-z_][a-z0-9_]*)\s*;/gm)) {
|
|
242
|
+
const name = match[1]
|
|
243
|
+
if (name !== undefined) local.push('mod:' + name)
|
|
244
|
+
}
|
|
245
|
+
for (const match of source.matchAll(/^\s*use\s+(crate|super|self)::([\w:]+)/gm)) {
|
|
246
|
+
const root = match[1]
|
|
247
|
+
const path = match[2]
|
|
248
|
+
if (root !== undefined && path !== undefined) local.push(root + '::' + path)
|
|
249
|
+
}
|
|
250
|
+
for (const match of source.matchAll(/^\s*use\s+([a-z_]\w*)::/gm)) {
|
|
251
|
+
const name = match[1]
|
|
252
|
+
if (name !== undefined && !['crate', 'super', 'self', 'std', 'core', 'alloc'].includes(name)) external.push(name)
|
|
253
|
+
}
|
|
254
|
+
return { local, external }
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const JAVA_LIKE: Language = {
|
|
259
|
+
name: 'java',
|
|
260
|
+
extensions: ['.java', '.kt', '.kts', '.scala'],
|
|
261
|
+
granularity: 'package',
|
|
262
|
+
extract(text) {
|
|
263
|
+
const source = stripCComments(text)
|
|
264
|
+
const project = /^\s*package\s+([\w.]+)/m.exec(source)?.[1] ?? ''
|
|
265
|
+
const external: string[] = []
|
|
266
|
+
const local: string[] = []
|
|
267
|
+
for (const match of source.matchAll(/^\s*import\s+(?:static\s+)?([\w.]+)/gm)) {
|
|
268
|
+
const name = match[1]
|
|
269
|
+
if (name === undefined) continue
|
|
270
|
+
// A package import says nothing about the project root, which is a build setting this scan does
|
|
271
|
+
// not read, so every import is offered to the resolver; one that names no directory here is a
|
|
272
|
+
// library and is counted as such.
|
|
273
|
+
local.push(name)
|
|
274
|
+
}
|
|
275
|
+
return { local, external }
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const CSHARP: Language = {
|
|
280
|
+
name: 'csharp',
|
|
281
|
+
extensions: ['.cs'],
|
|
282
|
+
granularity: 'package',
|
|
283
|
+
extract(text) {
|
|
284
|
+
const source = stripCComments(text)
|
|
285
|
+
const project = /^\s*namespace\s+([\w.]+)/m.exec(source)?.[1] ?? ''
|
|
286
|
+
const local: string[] = []
|
|
287
|
+
const external: string[] = []
|
|
288
|
+
for (const match of source.matchAll(/^\s*using\s+([\w.]+)\s*;/gm)) {
|
|
289
|
+
const name = match[1]
|
|
290
|
+
if (name === undefined) continue
|
|
291
|
+
// Same as a Java package: the namespace root is a project setting, so the resolver decides.
|
|
292
|
+
local.push(name)
|
|
293
|
+
}
|
|
294
|
+
return { local, external }
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const PHP: Language = {
|
|
299
|
+
name: 'php',
|
|
300
|
+
extensions: ['.php'],
|
|
301
|
+
granularity: 'file',
|
|
302
|
+
extract(text) {
|
|
303
|
+
const source = stripCComments(text)
|
|
304
|
+
const local: string[] = []
|
|
305
|
+
const external: string[] = []
|
|
306
|
+
for (const match of source.matchAll(/(?:require|require_once|include|include_once)\s*\(?\s*['"]([^'"]+)['"]/g)) {
|
|
307
|
+
const path = match[1]
|
|
308
|
+
if (path !== undefined) local.push(path)
|
|
309
|
+
}
|
|
310
|
+
for (const match of source.matchAll(/^\s*use\s+([\w\\]+)/gm)) {
|
|
311
|
+
const name = match[1]
|
|
312
|
+
if (name !== undefined) external.push(name.replaceAll('\\', '.'))
|
|
313
|
+
}
|
|
314
|
+
return { local, external }
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const RUBY: Language = {
|
|
319
|
+
name: 'ruby',
|
|
320
|
+
extensions: ['.rb'],
|
|
321
|
+
granularity: 'file',
|
|
322
|
+
extract(text) {
|
|
323
|
+
const source = stripHashComments(text)
|
|
324
|
+
const local: string[] = []
|
|
325
|
+
const external: string[] = []
|
|
326
|
+
for (const match of source.matchAll(/^\s*require_relative\s+['"]([^'"]+)['"]/gm)) {
|
|
327
|
+
const path = match[1]
|
|
328
|
+
if (path !== undefined) local.push(path)
|
|
329
|
+
}
|
|
330
|
+
for (const match of source.matchAll(/^\s*require\s+['"]([^'"]+)['"]/gm)) {
|
|
331
|
+
const path = match[1]
|
|
332
|
+
if (path !== undefined) external.push(path)
|
|
333
|
+
}
|
|
334
|
+
return { local, external }
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const LUA: Language = {
|
|
339
|
+
name: 'lua',
|
|
340
|
+
extensions: ['.lua'],
|
|
341
|
+
granularity: 'file',
|
|
342
|
+
extract(text) {
|
|
343
|
+
const source = stripHashComments(stripCComments(text))
|
|
344
|
+
const local: string[] = []
|
|
345
|
+
const external: string[] = []
|
|
346
|
+
for (const match of source.matchAll(/require\s*\(?\s*['"]([^'"]+)['"]/g)) {
|
|
347
|
+
const path = match[1]
|
|
348
|
+
if (path === undefined) continue
|
|
349
|
+
if (path.startsWith('.')) local.push(path)
|
|
350
|
+
else external.push(path)
|
|
351
|
+
}
|
|
352
|
+
return { local, external }
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const ZIG: Language = {
|
|
357
|
+
name: 'zig',
|
|
358
|
+
extensions: ['.zig'],
|
|
359
|
+
granularity: 'file',
|
|
360
|
+
extract(text) {
|
|
361
|
+
const local: string[] = []
|
|
362
|
+
const external: string[] = []
|
|
363
|
+
for (const match of stripCComments(text).matchAll(/@import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
364
|
+
const specifier = match[1]
|
|
365
|
+
if (specifier === undefined) continue
|
|
366
|
+
// `@import` always names a file in the tree, so there is no bare form to separate out.
|
|
367
|
+
local.push(specifier)
|
|
368
|
+
}
|
|
369
|
+
return { local, external }
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const DART: Language = {
|
|
374
|
+
name: 'dart',
|
|
375
|
+
extensions: ['.dart'],
|
|
376
|
+
granularity: 'file',
|
|
377
|
+
extract(text) {
|
|
378
|
+
const source = stripCComments(text)
|
|
379
|
+
const local: string[] = []
|
|
380
|
+
const external: string[] = []
|
|
381
|
+
for (const match of source.matchAll(/^\s*(?:import|export|part)\s+['"]([^'"]+)['"]/gm)) {
|
|
382
|
+
const path = match[1]
|
|
383
|
+
if (path === undefined) continue
|
|
384
|
+
if (path.startsWith('dart:') || path.startsWith('package:')) external.push(path)
|
|
385
|
+
else local.push(path)
|
|
386
|
+
}
|
|
387
|
+
return { local, external }
|
|
388
|
+
},
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const ELIXIR: Language = {
|
|
392
|
+
name: 'elixir',
|
|
393
|
+
extensions: ['.ex', '.exs'],
|
|
394
|
+
granularity: 'file',
|
|
395
|
+
extract(text) {
|
|
396
|
+
const source = stripHashComments(text)
|
|
397
|
+
const local: string[] = []
|
|
398
|
+
const external: string[] = []
|
|
399
|
+
for (const match of source.matchAll(/^\s*(?:alias|import|use)\s+([A-Z][\w.]*)/gm)) {
|
|
400
|
+
const name = match[1]
|
|
401
|
+
if (name === undefined) continue
|
|
402
|
+
local.push('mod:' + name)
|
|
403
|
+
}
|
|
404
|
+
return { local, external }
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const SWIFT: Language = {
|
|
409
|
+
name: 'swift',
|
|
410
|
+
extensions: ['.swift'],
|
|
411
|
+
granularity: 'package',
|
|
412
|
+
extract(text) {
|
|
413
|
+
const source = stripCComments(text)
|
|
414
|
+
const local: string[] = []
|
|
415
|
+
const external: string[] = []
|
|
416
|
+
for (const match of source.matchAll(/^\s*import\s+(?:\w+\s+)?([\w.]+)/gm)) {
|
|
417
|
+
const name = match[1]
|
|
418
|
+
if (name === undefined) continue
|
|
419
|
+
// A module name is not a path, and the module-to-file map is a build setting this scan does not
|
|
420
|
+
// read, so a Swift import is reported as external instead of being guessed into an edge.
|
|
421
|
+
external.push(name)
|
|
422
|
+
}
|
|
423
|
+
return { local, external }
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Every language this scan understands, by extension. */
|
|
428
|
+
export const LANGUAGES: Language[] = [JS_LIKE, C_LIKE, PYTHON, GO, RUST, JAVA_LIKE, CSHARP, PHP, RUBY, LUA, ZIG, DART, ELIXIR, SWIFT]
|
|
429
|
+
|
|
430
|
+
const BY_EXTENSION = new Map<string, Language>(LANGUAGES.flatMap((language) => language.extensions.map((extension) => [extension, language] as const)))
|
|
431
|
+
|
|
432
|
+
/** Which language a file belongs to, if any. */
|
|
433
|
+
export function languageOf(path: string): Language | undefined {
|
|
434
|
+
return BY_EXTENSION.get(extname(path).toLowerCase())
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ------------------------------------------------------------------------------------------ resolution --
|
|
438
|
+
|
|
439
|
+
interface Index {
|
|
440
|
+
/** Every scanned file id. */
|
|
441
|
+
files: Set<string>
|
|
442
|
+
/** Declared path aliases, so `@/lib/a` resolves the way the project resolves it. */
|
|
443
|
+
aliases: Alias[]
|
|
444
|
+
/** `compilerOptions.baseUrl`, for a bare specifier that names a file under it. */
|
|
445
|
+
baseUrl?: string
|
|
446
|
+
/** Workspace package name -> directory, from the root `package.json`. */
|
|
447
|
+
workspaces: Map<string, string>
|
|
448
|
+
/** Extra `#include` search directories, from `compile_commands.json`. */
|
|
449
|
+
includeDirs: string[]
|
|
450
|
+
/** Package-language roots this tree owns: the `module` in `go.mod`, and the top-level package names. */
|
|
451
|
+
internalRoots: string[]
|
|
452
|
+
/** Directory ids, so a package language can resolve a package name to a directory. */
|
|
453
|
+
directories: string[]
|
|
454
|
+
/** Directory ids by their dotted form (`a/b` for both `a.b` and `a/b`). */
|
|
455
|
+
byDotted: Map<string, string>
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const tryFiles = (base: string, extensions: string[], index: Index): string | undefined => {
|
|
459
|
+
const candidates = [base]
|
|
460
|
+
for (const extension of extensions) candidates.push(base + extension)
|
|
461
|
+
for (const extension of extensions) candidates.push(base + '/index' + extension)
|
|
462
|
+
return candidates.find((candidate) => index.files.has(candidate))
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Resolve one specifier to a module id, following the language's own rules. */
|
|
466
|
+
function resolveSpecifier(language: Language, fromId: string, specifier: string, index: Index): string | undefined {
|
|
467
|
+
if (language.granularity === 'package') {
|
|
468
|
+
// A package language names a package, not a file: strip the member and match a directory.
|
|
469
|
+
// A Java import separates with dots and a Go import with slashes, and a Go module path contains
|
|
470
|
+
// both, so the specifier is split on either and the pieces are matched from both ends.
|
|
471
|
+
const dotted = specifier.replaceAll('\\', '/').split(/[/.]/).filter(Boolean)
|
|
472
|
+
// Two shapes meet here. A Java or C# import carries the package *root* and then the member, so the
|
|
473
|
+
// directory is a prefix of the specifier. A Go import carries the module path and then the package,
|
|
474
|
+
// so the directory is a suffix. Both are tried, exact package names first.
|
|
475
|
+
const candidates: string[] = []
|
|
476
|
+
for (let end = dotted.length; end > 0; end--) candidates.push(dotted.slice(0, end).join('/'))
|
|
477
|
+
for (let start = 1; start < dotted.length; start++) candidates.push(dotted.slice(start).join('/'))
|
|
478
|
+
for (const candidate of candidates) {
|
|
479
|
+
// The index keys a directory by its dotted form, which is what a Java or C# specifier already is.
|
|
480
|
+
const hit = index.byDotted.get(candidate.replaceAll('/', '.'))
|
|
481
|
+
if (hit !== undefined) return hit
|
|
482
|
+
const tail = index.directories.find((directory) => directory === candidate || directory.endsWith('/' + candidate))
|
|
483
|
+
if (tail !== undefined) return tail
|
|
484
|
+
}
|
|
485
|
+
return undefined
|
|
486
|
+
}
|
|
487
|
+
if (language.name === 'python') {
|
|
488
|
+
if (specifier.startsWith('.')) {
|
|
489
|
+
// `.x` is the current package, `..x` is one level up; the dot count is how many levels to climb.
|
|
490
|
+
const dots = /^\.+/.exec(specifier)?.[0].length ?? 1
|
|
491
|
+
let base = dirOf(fromId)
|
|
492
|
+
for (let level = 1; level < dots; level++) base = dirOf(base)
|
|
493
|
+
return tryFiles(normalise([base, specifier.slice(dots).replaceAll('.', '/')]), ['.py', '.pyi'], index)
|
|
494
|
+
}
|
|
495
|
+
return tryFiles(specifier.replaceAll('.', '/'), ['.py', '.pyi'], index)
|
|
496
|
+
}
|
|
497
|
+
if (language.name === 'rust') {
|
|
498
|
+
if (specifier.startsWith('mod:')) {
|
|
499
|
+
const name = specifier.slice(4)
|
|
500
|
+
return tryFiles(normalise([dirOf(fromId), name]), ['.rs'], index) ?? tryFiles(normalise([dirOf(fromId), name, 'mod']), ['.rs'], index)
|
|
501
|
+
}
|
|
502
|
+
const [root, path] = specifier.split('::')
|
|
503
|
+
const segments = (path ?? '').split('::').filter(Boolean)
|
|
504
|
+
const base = root === 'crate' ? '' : dirOf(fromId)
|
|
505
|
+
// A use path names a module or an item inside it; try the longest prefix that exists.
|
|
506
|
+
for (let length = segments.length; length > 0; length--) {
|
|
507
|
+
const hit = tryFiles(normalise([base, segments.slice(0, length).join('/')]), ['.rs'], index)
|
|
508
|
+
if (hit !== undefined) return hit
|
|
509
|
+
}
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
512
|
+
if (language.name === 'elixir') {
|
|
513
|
+
const name = specifier.startsWith('mod:') ? specifier.slice(4) : specifier
|
|
514
|
+
const candidate = name.split('.').map((part) => part.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()).join('/')
|
|
515
|
+
return tryFiles(candidate, ['.ex', '.exs'], index)
|
|
516
|
+
}
|
|
517
|
+
if (language.name === 'swift') {
|
|
518
|
+
return index.byDotted.get(specifier.toLowerCase())
|
|
519
|
+
}
|
|
520
|
+
if (specifier.startsWith('/')) return undefined
|
|
521
|
+
if (!specifier.startsWith('.')) {
|
|
522
|
+
// A bare specifier is resolved the way the project declares it: a path alias first, then `baseUrl`.
|
|
523
|
+
// That is also the only way a bare specifier can be project-internal, so the same helper decides
|
|
524
|
+
// whether one is an edge or a package.
|
|
525
|
+
const declared = resolveDeclared(language, specifier, index)
|
|
526
|
+
if (declared !== undefined) return declared
|
|
527
|
+
}
|
|
528
|
+
// A C/C++ include that is not beside the importing file is usually found through an include directory
|
|
529
|
+
// the build already knows about, so `compile_commands.json` is read rather than asked for.
|
|
530
|
+
if (language.name === 'c' && !specifier.startsWith('.')) {
|
|
531
|
+
for (const directory of index.includeDirs) {
|
|
532
|
+
const hit = tryFiles(normalise([directory, specifier]), language.extensions, index)
|
|
533
|
+
if (hit !== undefined) return hit
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const base = joinPath(fromId, specifier)
|
|
537
|
+
const extension = extname(base)
|
|
538
|
+
const stem = extension === '' ? base : base.slice(0, -extension.length)
|
|
539
|
+
// A specifier may carry an extension the language does not store (TypeScript writes `./x.js` for a
|
|
540
|
+
// `.ts` source), so the stem is tried against the language's own extensions as well.
|
|
541
|
+
const candidates = extension === ''
|
|
542
|
+
? [base, ...language.extensions.map((entry) => base + entry), ...language.extensions.map((entry) => base + '/index' + entry)]
|
|
543
|
+
: [base, ...language.extensions.map((entry) => stem + entry)]
|
|
544
|
+
return candidates.find((candidate) => index.files.has(candidate))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ------------------------------------------------------------------------------------------------ scan --
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* A bare specifier that the project itself declares: a `paths` alias, or a file under `baseUrl`.
|
|
552
|
+
*
|
|
553
|
+
* `@/lib/a` is not a package, and counting it as one dropped the edge without saying so. Returns
|
|
554
|
+
* undefined when nothing is declared for it, which leaves the caller free to treat it as external.
|
|
555
|
+
*/
|
|
556
|
+
function resolveDeclared(language: Language, specifier: string, index: Index): string | undefined {
|
|
557
|
+
// A workspace package is the project too: `import { x } from '@acme/ui'` in a monorepo is an edge, not
|
|
558
|
+
// an external dependency. The name comes from the package's own manifest.
|
|
559
|
+
for (const [name, directory] of index.workspaces) {
|
|
560
|
+
if (specifier !== name && !specifier.startsWith(name + '/')) continue
|
|
561
|
+
const rest = specifier === name ? '' : specifier.slice(name.length + 1)
|
|
562
|
+
const hit = tryFiles(normalise([directory, rest]), language.extensions, index)
|
|
563
|
+
?? tryFiles(normalise([directory]), language.extensions, index)
|
|
564
|
+
?? tryFiles(normalise([directory, 'src']), language.extensions, index)
|
|
565
|
+
if (hit !== undefined) return hit
|
|
566
|
+
}
|
|
567
|
+
for (const alias of index.aliases) {
|
|
568
|
+
if (!specifier.startsWith(alias.prefix)) continue
|
|
569
|
+
const rest = specifier.slice(alias.prefix.length)
|
|
570
|
+
for (const target of alias.targets) {
|
|
571
|
+
const hit = tryFiles(normalise([target + rest]), language.extensions, index)
|
|
572
|
+
if (hit !== undefined) return hit
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (index.baseUrl !== undefined) {
|
|
576
|
+
return tryFiles(normalise([index.baseUrl, specifier]), language.extensions, index)
|
|
577
|
+
}
|
|
578
|
+
return undefined
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Does this package-language specifier name something this tree owns?
|
|
583
|
+
*
|
|
584
|
+
* `com.acme.b.B` in a tree whose top level is `com/`, and `example.com/proj/pkg/b` in a module whose
|
|
585
|
+
* `go.mod` says `module example.com/proj`, are both internal: if they do not resolve, the reference is
|
|
586
|
+
* broken. `java.util.List` and `lodash` are not.
|
|
587
|
+
*/
|
|
588
|
+
function isInternalSpecifier(specifier: string, roots: string[]): boolean {
|
|
589
|
+
const head = specifier.replaceAll('\\', '/').split(/[/.]/).filter(Boolean)[0]
|
|
590
|
+
if (head === undefined) return false
|
|
591
|
+
return roots.some((root) => root === head || specifier.startsWith(root + '/') || specifier.startsWith(root + '.'))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Workspace packages declared by the root manifest, so a monorepo's own names resolve inside the tree. */
|
|
595
|
+
function readWorkspaces(root: string): Map<string, string> {
|
|
596
|
+
const names = new Map<string, string>()
|
|
597
|
+
let manifest: unknown
|
|
598
|
+
try {
|
|
599
|
+
manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
|
|
600
|
+
} catch {
|
|
601
|
+
return names
|
|
602
|
+
}
|
|
603
|
+
if (typeof manifest !== 'object' || manifest === null) return names
|
|
604
|
+
const declared = (manifest as { workspaces?: unknown }).workspaces
|
|
605
|
+
const patterns = Array.isArray(declared)
|
|
606
|
+
? declared
|
|
607
|
+
: typeof declared === 'object' && declared !== null && Array.isArray((declared as { packages?: unknown }).packages)
|
|
608
|
+
? ((declared as { packages: unknown[] }).packages)
|
|
609
|
+
: []
|
|
610
|
+
for (const pattern of patterns) {
|
|
611
|
+
if (typeof pattern !== 'string' || pattern.includes('*')) continue
|
|
612
|
+
const directory = pattern.replace(/^\.\//, '').replace(/\/$/, '')
|
|
613
|
+
try {
|
|
614
|
+
const entry = JSON.parse(readFileSync(resolve(root, directory, 'package.json'), 'utf8')) as { name?: unknown }
|
|
615
|
+
if (typeof entry.name === 'string' && entry.name !== '') names.set(entry.name, directory)
|
|
616
|
+
} catch {
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return names
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** `-I`/`-isystem` directories from `compile_commands.json`, kept inside the scanned tree. */
|
|
624
|
+
function readIncludeDirs(root: string): string[] {
|
|
625
|
+
let entries: unknown
|
|
626
|
+
try {
|
|
627
|
+
entries = JSON.parse(readFileSync(resolve(root, 'compile_commands.json'), 'utf8'))
|
|
628
|
+
} catch {
|
|
629
|
+
return []
|
|
630
|
+
}
|
|
631
|
+
if (!Array.isArray(entries)) return []
|
|
632
|
+
const dirs = new Set<string>()
|
|
633
|
+
for (const entry of entries) {
|
|
634
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
635
|
+
const record = entry as { directory?: unknown; command?: unknown; arguments?: unknown }
|
|
636
|
+
const declared = typeof record.directory === 'string' ? record.directory.replaceAll(String.fromCharCode(92), '/') : '.'
|
|
637
|
+
const base = declared.startsWith('/') || /^[A-Za-z]:/.test(declared) ? declared : resolve(root, declared).replaceAll(String.fromCharCode(92), '/')
|
|
638
|
+
const args: string[] = Array.isArray(record.arguments)
|
|
639
|
+
? record.arguments.filter((argument): argument is string => typeof argument === 'string')
|
|
640
|
+
: typeof record.command === 'string'
|
|
641
|
+
? record.command.split(/\s+/)
|
|
642
|
+
: []
|
|
643
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
644
|
+
const argument = args[index]
|
|
645
|
+
let value: string | undefined
|
|
646
|
+
if (argument === '-I' || argument === '-isystem') value = args[index + 1]
|
|
647
|
+
else if (argument.startsWith('-I')) value = argument.slice(2)
|
|
648
|
+
else if (argument.startsWith('-isystem')) value = argument.slice(8)
|
|
649
|
+
if (value === undefined || value === '') continue
|
|
650
|
+
const absolute = value.startsWith('/') || /^[A-Za-z]:/.test(value) ? value : base + '/' + value
|
|
651
|
+
const local = relative(root, absolute).replaceAll(String.fromCharCode(92), '/')
|
|
652
|
+
if (local.startsWith('..')) continue
|
|
653
|
+
dirs.add(local === '' ? '.' : local)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return [...dirs].sort()
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
/** The package roots this tree owns: `module` from `go.mod`, and the top-level names on disk. */
|
|
661
|
+
function internalRootsOf(root: string, directories: string[]): string[] {
|
|
662
|
+
const roots = new Set<string>()
|
|
663
|
+
for (const directory of directories) {
|
|
664
|
+
const head = directory.split('/')[0]
|
|
665
|
+
if (head !== undefined && head !== '') roots.add(head)
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
for (const line of readFileSync(resolve(root, 'go.mod'), 'utf8').split(String.fromCharCode(10)).map((entry) => entry.trim())) {
|
|
669
|
+
const match = /^module\s+(\S+)/.exec(line)
|
|
670
|
+
if (match?.[1] !== undefined) roots.add(match[1])
|
|
671
|
+
}
|
|
672
|
+
} catch {
|
|
673
|
+
// No go.mod: the top-level names are the whole set.
|
|
674
|
+
}
|
|
675
|
+
return [...roots].sort()
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function scanDependencyGraph(root: unknown): ScannedGraph {
|
|
679
|
+
if (typeof root !== 'string' || root.trim() === '') {
|
|
680
|
+
throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'repoPath must be a non-empty path to a directory or a single source file.')
|
|
681
|
+
}
|
|
682
|
+
const absolute = resolve(root)
|
|
683
|
+
let stat
|
|
684
|
+
try {
|
|
685
|
+
stat = statSync(absolute)
|
|
686
|
+
} catch (error) {
|
|
687
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
688
|
+
throw new OptionShapeError('STRUCTURE_OPTION_INVALID', `repoPath could not be read: ${reason}. The path resolves against the process working directory, which is the session workspace.`)
|
|
689
|
+
}
|
|
690
|
+
if (stat.isFile()) {
|
|
691
|
+
const id = relative(resolve('/'), absolute).slice(1).replaceAll('\\', '/')
|
|
692
|
+
return { nodes: [id], edges: [], filesRead: 1, unresolved: [], packages: [], dynamic: 0, languages: [] }
|
|
693
|
+
}
|
|
694
|
+
if (!stat.isDirectory()) {
|
|
695
|
+
throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'repoPath must be a directory or a source file.')
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const files: string[] = []
|
|
699
|
+
const contents = new Map<string, string>()
|
|
700
|
+
const byLanguage = new Map<string, number>()
|
|
701
|
+
const walk = (directory: string): void => {
|
|
702
|
+
if (files.length >= LIMITS.files) return
|
|
703
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
704
|
+
if (files.length >= LIMITS.files) return
|
|
705
|
+
if (entry.isSymbolicLink()) continue
|
|
706
|
+
const full = resolve(directory, entry.name)
|
|
707
|
+
if (entry.isDirectory()) {
|
|
708
|
+
if (SKIP.has(entry.name)) continue
|
|
709
|
+
walk(full)
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
if (!entry.isFile()) continue
|
|
713
|
+
const language = languageOf(entry.name)
|
|
714
|
+
if (language === undefined) continue
|
|
715
|
+
const id = relative(absolute, full).replaceAll('\\', '/')
|
|
716
|
+
let text
|
|
717
|
+
try {
|
|
718
|
+
if (statSync(full).size > LIMITS.bytes) continue
|
|
719
|
+
text = readFileSync(full, 'utf8')
|
|
720
|
+
} catch {
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
files.push(id)
|
|
724
|
+
contents.set(id, text)
|
|
725
|
+
byLanguage.set(language.name, (byLanguage.get(language.name) ?? 0) + 1)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
walk(absolute)
|
|
729
|
+
files.sort()
|
|
730
|
+
|
|
731
|
+
const fileSet = new Set(files)
|
|
732
|
+
const directories = [...new Set(files.map((id) => dirOf(id)).filter((id) => id !== ''))].sort()
|
|
733
|
+
const byDotted = new Map<string, string>()
|
|
734
|
+
for (const directory of directories) byDotted.set(directory.replaceAll('/', '.'), directory)
|
|
735
|
+
const index: Index = {
|
|
736
|
+
files: fileSet,
|
|
737
|
+
directories,
|
|
738
|
+
byDotted,
|
|
739
|
+
...readAliases(absolute),
|
|
740
|
+
workspaces: readWorkspaces(absolute),
|
|
741
|
+
includeDirs: readIncludeDirs(absolute),
|
|
742
|
+
internalRoots: internalRootsOf(absolute, directories),
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const edges: ScannedEdge[] = []
|
|
746
|
+
const seen = new Set<string>()
|
|
747
|
+
const unresolved: string[] = []
|
|
748
|
+
const packages = new Set<string>()
|
|
749
|
+
const nodeSet = new Set<string>()
|
|
750
|
+
let dynamic = 0
|
|
751
|
+
|
|
752
|
+
for (const id of files) {
|
|
753
|
+
const language = languageOf(id) as Language
|
|
754
|
+
const { local, external, dynamic: dynamicHere } = language.extract(contents.get(id) ?? '')
|
|
755
|
+
dynamic += dynamicHere ?? 0
|
|
756
|
+
const nodeOf = (fileId: string): string => (language.granularity === 'package' ? (dirOf(fileId) === '' ? '.' : dirOf(fileId)) : fileId)
|
|
757
|
+
const from = nodeOf(id)
|
|
758
|
+
nodeSet.add(from)
|
|
759
|
+
for (const specifier of local) {
|
|
760
|
+
const target = resolveSpecifier(language, id, specifier, index)
|
|
761
|
+
if (target === undefined) {
|
|
762
|
+
// A package language names modules by a path this tree may or may not contain. When the path
|
|
763
|
+
// starts at a root this tree owns - the `go.mod` module, a top-level package directory - it is a
|
|
764
|
+
// broken reference and has to be reported; anything else is an external dependency.
|
|
765
|
+
if (language.granularity === 'package' && !isInternalSpecifier(specifier, index.internalRoots)) {
|
|
766
|
+
const name = specifier.split(/[/.]/).filter(Boolean)[0]
|
|
767
|
+
if (name !== undefined) packages.add(name)
|
|
768
|
+
} else {
|
|
769
|
+
unresolved.push(`${id} -> ${specifier}`)
|
|
770
|
+
}
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
// A package resolver already answers with a directory; only a file resolver answers with a file.
|
|
774
|
+
const to = target
|
|
775
|
+
nodeSet.add(to)
|
|
776
|
+
if (to === from) continue
|
|
777
|
+
const key = from + '\u0000' + to
|
|
778
|
+
if (seen.has(key)) continue
|
|
779
|
+
seen.add(key)
|
|
780
|
+
edges.push({ from, to })
|
|
781
|
+
}
|
|
782
|
+
for (const specifier of external) {
|
|
783
|
+
const internal = language.granularity === 'file' ? resolveDeclared(language, specifier, index) : undefined
|
|
784
|
+
if (internal !== undefined) {
|
|
785
|
+
const target = nodeOf(internal)
|
|
786
|
+
nodeSet.add(target)
|
|
787
|
+
if (target !== from) {
|
|
788
|
+
const key = from + '' + target
|
|
789
|
+
if (!seen.has(key)) {
|
|
790
|
+
seen.add(key)
|
|
791
|
+
edges.push({ from, to: target })
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
continue
|
|
795
|
+
}
|
|
796
|
+
const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split(/[/.:]/)[0]
|
|
797
|
+
if (name !== undefined && name !== '') packages.add(name)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const languages = [...byLanguage.entries()]
|
|
802
|
+
.map(([name, count]) => ({ language: name, files: count, granularity: (LANGUAGES.find((language) => language.name === name)?.granularity ?? 'file') as LangGranularity }))
|
|
803
|
+
.sort((a, b) => b.files - a.files)
|
|
804
|
+
edges.sort((a, b) => (a.from === b.from ? a.to.localeCompare(b.to) : a.from.localeCompare(b.to)))
|
|
805
|
+
unresolved.sort()
|
|
806
|
+
return { nodes: [...nodeSet].sort(), edges, filesRead: files.length, unresolved, packages: [...packages].sort(), dynamic, languages }
|
|
807
|
+
}
|