dsh-model-everything 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.en-US.md +75 -19
  2. package/README.md +377 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +3494 -0
  6. package/lib/diagram-diff-tool.js +90 -0
  7. package/lib/diagram-render-tool.js +102 -0
  8. package/lib/diagram-review-tool.js +99 -0
  9. package/lib/diagram.js +43 -0
  10. package/lib/gate.js +14 -12
  11. package/lib/presentation.js +10 -0
  12. package/lib/render/budget.js +99 -0
  13. package/lib/render/channels.js +243 -0
  14. package/lib/render/collapse.js +155 -0
  15. package/lib/render/components.js +99 -0
  16. package/lib/render/coords.js +288 -0
  17. package/lib/render/cycle-break.js +147 -0
  18. package/lib/render/diff.js +138 -0
  19. package/lib/render/dummy.js +43 -0
  20. package/lib/render/from-mermaid.js +235 -0
  21. package/lib/render/from-plantuml.js +430 -0
  22. package/lib/render/index.js +42 -0
  23. package/lib/render/layer.js +91 -0
  24. package/lib/render/layout.js +68 -0
  25. package/lib/render/model.js +17 -0
  26. package/lib/render/network-simplex.js +272 -0
  27. package/lib/render/order.js +122 -0
  28. package/lib/render/paint.js +891 -0
  29. package/lib/render/palette.js +127 -0
  30. package/lib/render/place.js +482 -0
  31. package/lib/render/route.js +225 -0
  32. package/lib/render/svg.js +229 -0
  33. package/lib/render/symbol.js +108 -0
  34. package/lib/render/text.js +74 -0
  35. package/lib/render/viewmodel.js +126 -0
  36. package/lib/render/wrap.js +87 -0
  37. package/lib/render/write.js +14 -0
  38. package/lib/scan.js +248 -8
  39. package/lib/structure-tool.js +2 -2
  40. package/lib/types/client.d.ts +83 -0
  41. package/lib/types/diagram-diff-tool.d.ts +8 -0
  42. package/lib/types/diagram-render-tool.d.ts +8 -0
  43. package/lib/types/diagram-review-tool.d.ts +8 -0
  44. package/lib/types/diagram.d.ts +34 -0
  45. package/lib/types/presentation.d.ts +7 -0
  46. package/lib/types/render/budget.d.ts +55 -0
  47. package/lib/types/render/channels.d.ts +63 -0
  48. package/lib/types/render/collapse.d.ts +23 -0
  49. package/lib/types/render/components.d.ts +57 -0
  50. package/lib/types/render/coords.d.ts +64 -0
  51. package/lib/types/render/cycle-break.d.ts +45 -0
  52. package/lib/types/render/diff.d.ts +44 -0
  53. package/lib/types/render/dummy.d.ts +32 -0
  54. package/lib/types/render/from-mermaid.d.ts +27 -0
  55. package/lib/types/render/from-plantuml.d.ts +33 -0
  56. package/lib/types/render/index.d.ts +38 -0
  57. package/lib/types/render/layer.d.ts +28 -0
  58. package/lib/types/render/layout.d.ts +54 -0
  59. package/lib/types/render/model.d.ts +73 -0
  60. package/lib/types/render/network-simplex.d.ts +54 -0
  61. package/lib/types/render/order.d.ts +27 -0
  62. package/lib/types/render/paint.d.ts +127 -0
  63. package/lib/types/render/palette.d.ts +41 -0
  64. package/lib/types/render/place.d.ts +55 -0
  65. package/lib/types/render/route.d.ts +77 -0
  66. package/lib/types/render/svg.d.ts +19 -0
  67. package/lib/types/render/symbol.d.ts +73 -0
  68. package/lib/types/render/text.d.ts +38 -0
  69. package/lib/types/render/viewmodel.d.ts +20 -0
  70. package/lib/types/render/wrap.d.ts +27 -0
  71. package/lib/types/render/write.d.ts +14 -0
  72. package/lib/types/scan.d.ts +10 -2
  73. package/lib/types/structure.d.ts +1 -0
  74. package/package.json +148 -132
  75. package/skills/model-everything/SKILL.md +454 -454
  76. package/skills/model-everything/references/architecture-domains.md +137 -0
  77. package/skills/model-everything/references/architecture-views.md +119 -0
  78. package/skills/model-everything-diagram/SKILL.md +84 -0
  79. package/src/client.ts +307 -0
  80. package/src/diagram-diff-tool.ts +94 -0
  81. package/src/diagram-render-tool.ts +108 -0
  82. package/src/diagram-review-tool.ts +109 -0
  83. package/src/diagram.ts +61 -0
  84. package/src/gate.ts +14 -12
  85. package/src/presentation.ts +12 -0
  86. package/src/render/budget.ts +135 -0
  87. package/src/render/channels.ts +309 -0
  88. package/src/render/collapse.ts +150 -0
  89. package/src/render/components.ts +126 -0
  90. package/src/render/coords.ts +328 -0
  91. package/src/render/cycle-break.ts +164 -0
  92. package/src/render/diff.ts +162 -0
  93. package/src/render/dummy.ts +65 -0
  94. package/src/render/from-mermaid.ts +261 -0
  95. package/src/render/from-plantuml.ts +469 -0
  96. package/src/render/index.ts +73 -0
  97. package/src/render/layer.ts +98 -0
  98. package/src/render/layout.ts +101 -0
  99. package/src/render/model.ts +86 -0
  100. package/src/render/network-simplex.ts +290 -0
  101. package/src/render/order.ts +139 -0
  102. package/src/render/paint.ts +1006 -0
  103. package/src/render/palette.ts +164 -0
  104. package/src/render/place.ts +527 -0
  105. package/src/render/route.ts +269 -0
  106. package/src/render/svg.ts +270 -0
  107. package/src/render/symbol.ts +137 -0
  108. package/src/render/text.ts +86 -0
  109. package/src/render/viewmodel.ts +136 -0
  110. package/src/render/wrap.ts +103 -0
  111. package/src/render/write.ts +17 -0
  112. package/src/scan.ts +807 -564
  113. package/src/structure-tool.ts +2 -2
  114. 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
- /** Relative specifiers that pointed outside the root or at a file that does not exist. */
35
- unresolved: string[]
36
- /** Bare specifiers seen (a package name), counted once each. */
37
- packages: string[]
38
- /** Which languages were scanned, and at what granularity. */
39
- languages: { language: string; files: number; granularity: 'file' | 'package' }[]
40
- }
41
-
42
- const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.cache', 'target', 'vendor', '__pycache__', '.venv', 'venv'])
43
- const LIMITS = { files: 5000, bytes: 2_000_000 }
44
-
45
- /** Strip comments so a commented-out include is not an edge. */
46
- const stripCComments = (text: string): string => text.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/(^|[^:])\/\/[^\n]*/g, '$1 ')
47
- const stripHashComments = (text: string): string => text.replaceAll(/(^|\s)#[^\n]*/g, '$1 ')
48
-
49
- const dirOf = (id: string): string => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '')
50
- const normalise = (parts: string[]): string => {
51
- const stack: string[] = []
52
- // Each entry may itself be a path: a caller passes a directory and a specifier, and only splitting
53
- // both is what makes `./x.js` resolve to `x.js` instead of to a segment named `./x.js`.
54
- for (const entry of parts) {
55
- for (const part of entry.split('/')) {
56
- if (part === '' || part === '.') continue
57
- if (part === '..') stack.pop()
58
- else stack.push(part)
59
- }
60
- }
61
- return stack.join('/')
62
- }
63
- const joinPath = (fromId: string, specifier: string): string => normalise([dirOf(fromId), specifier])
64
-
65
- // ------------------------------------------------------------------------------------------ languages --
66
-
67
- export type LangGranularity = 'file' | 'package'
68
-
69
- export interface Language {
70
- name: string
71
- extensions: string[]
72
- granularity: LangGranularity
73
- /** Specifiers this file names, already separated from external ones. */
74
- extract(text: string): { local: string[]; external: string[] }
75
- }
76
-
77
- const specifiers = (text: string, patterns: RegExp[]): { local: string[]; external: string[] } => {
78
- const local: string[] = []
79
- const external: string[] = []
80
- for (const pattern of patterns) {
81
- for (const match of text.matchAll(pattern)) {
82
- const specifier = match[1]
83
- if (specifier === undefined || specifier === '') continue
84
- if (specifier.startsWith('.') || specifier.startsWith('/')) local.push(specifier)
85
- else external.push(specifier)
86
- }
87
- }
88
- return { local, external }
89
- }
90
-
91
- const JS_LIKE: Language = {
92
- name: 'typescript',
93
- extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'],
94
- granularity: 'file',
95
- extract(text) {
96
- const source = stripCComments(text)
97
- 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])
98
- return { local, external: external.filter((name) => !name.startsWith('node:')) }
99
- },
100
- }
101
-
102
- const C_LIKE: Language = {
103
- name: 'c',
104
- extensions: ['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx', '.ino'],
105
- granularity: 'file',
106
- extract(text) {
107
- const local: string[] = []
108
- const external: string[] = []
109
- for (const match of stripCComments(text).matchAll(/^[ \t]*#[ \t]*include[ \t]*([<"])([^>"]+)[>"]/gm)) {
110
- const specifier = match[2]
111
- if (specifier === undefined) continue
112
- // Quoted includes are the project's own files; angle brackets are the system's, and a system
113
- // header is not an edge in this tree.
114
- if (match[1] === '"') local.push(specifier)
115
- else external.push(specifier)
116
- }
117
- return { local, external }
118
- },
119
- }
120
-
121
- const PYTHON: Language = {
122
- name: 'python',
123
- extensions: ['.py', '.pyi'],
124
- granularity: 'file',
125
- extract(text) {
126
- const source = stripHashComments(text)
127
- const local: string[] = []
128
- const external: string[] = []
129
- for (const match of source.matchAll(/^[ \t]*from[ \t]+(\.*)([\w.]*)[ \t]+import\b/gm)) {
130
- const dots = match[1] ?? ''
131
- const module = match[2] ?? ''
132
- if (dots !== '') local.push(dots + module)
133
- else if (module !== '') external.push(module)
134
- }
135
- for (const match of source.matchAll(/^[ \t]*import[ \t]+([\w.]+)/gm)) {
136
- const module = match[1]
137
- if (module !== undefined) external.push(module)
138
- }
139
- return { local, external }
140
- },
141
- }
142
-
143
- const GO: Language = {
144
- name: 'go',
145
- extensions: ['.go'],
146
- granularity: 'package',
147
- extract(text) {
148
- const source = stripCComments(text)
149
- const local: string[] = []
150
- const external: string[] = []
151
- // A block import is the common form; the single form is rare but legal.
152
- for (const block of source.matchAll(/import\s*\(([\s\S]*?)\)/g)) {
153
- for (const entry of (block[1] ?? '').matchAll(/"([^"]+)"/g)) {
154
- const path = entry[1]
155
- // A module path says nothing about where the package lives relative to this file, so the
156
- // resolver decides: a path whose tail names a directory here is an edge, the rest are modules.
157
- if (path !== undefined) local.push(path)
158
- }
159
- }
160
- for (const match of source.matchAll(/^\s*import\s+"([^"]+)"/gm)) {
161
- const path = match[1]
162
- if (path !== undefined) local.push(path)
163
- }
164
- return { local, external }
165
- },
166
- }
167
-
168
- const RUST: Language = {
169
- name: 'rust',
170
- extensions: ['.rs'],
171
- granularity: 'file',
172
- extract(text) {
173
- const source = stripCComments(text)
174
- const local: string[] = []
175
- const external: string[] = []
176
- for (const match of source.matchAll(/^\s*(?:pub\s+)?mod\s+([a-z_][a-z0-9_]*)\s*;/gm)) {
177
- const name = match[1]
178
- if (name !== undefined) local.push('mod:' + name)
179
- }
180
- for (const match of source.matchAll(/^\s*use\s+(crate|super|self)::([\w:]+)/gm)) {
181
- const root = match[1]
182
- const path = match[2]
183
- if (root !== undefined && path !== undefined) local.push(root + '::' + path)
184
- }
185
- for (const match of source.matchAll(/^\s*use\s+([a-z_]\w*)::/gm)) {
186
- const name = match[1]
187
- if (name !== undefined && !['crate', 'super', 'self', 'std', 'core', 'alloc'].includes(name)) external.push(name)
188
- }
189
- return { local, external }
190
- },
191
- }
192
-
193
- const JAVA_LIKE: Language = {
194
- name: 'java',
195
- extensions: ['.java', '.kt', '.kts', '.scala'],
196
- granularity: 'package',
197
- extract(text) {
198
- const source = stripCComments(text)
199
- const project = /^\s*package\s+([\w.]+)/m.exec(source)?.[1] ?? ''
200
- const external: string[] = []
201
- const local: string[] = []
202
- for (const match of source.matchAll(/^\s*import\s+(?:static\s+)?([\w.]+)/gm)) {
203
- const name = match[1]
204
- if (name === undefined) continue
205
- // A package import says nothing about the project root, which is a build setting this scan does
206
- // not read, so every import is offered to the resolver; one that names no directory here is a
207
- // library and is counted as such.
208
- local.push(name)
209
- }
210
- return { local, external }
211
- },
212
- }
213
-
214
- const CSHARP: Language = {
215
- name: 'csharp',
216
- extensions: ['.cs'],
217
- granularity: 'package',
218
- extract(text) {
219
- const source = stripCComments(text)
220
- const project = /^\s*namespace\s+([\w.]+)/m.exec(source)?.[1] ?? ''
221
- const local: string[] = []
222
- const external: string[] = []
223
- for (const match of source.matchAll(/^\s*using\s+([\w.]+)\s*;/gm)) {
224
- const name = match[1]
225
- if (name === undefined) continue
226
- // Same as a Java package: the namespace root is a project setting, so the resolver decides.
227
- local.push(name)
228
- }
229
- return { local, external }
230
- },
231
- }
232
-
233
- const PHP: Language = {
234
- name: 'php',
235
- extensions: ['.php'],
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(/(?:require|require_once|include|include_once)\s*\(?\s*['"]([^'"]+)['"]/g)) {
242
- const path = match[1]
243
- if (path !== undefined) local.push(path)
244
- }
245
- for (const match of source.matchAll(/^\s*use\s+([\w\\]+)/gm)) {
246
- const name = match[1]
247
- if (name !== undefined) external.push(name.replaceAll('\\', '.'))
248
- }
249
- return { local, external }
250
- },
251
- }
252
-
253
- const RUBY: Language = {
254
- name: 'ruby',
255
- extensions: ['.rb'],
256
- granularity: 'file',
257
- extract(text) {
258
- const source = stripHashComments(text)
259
- const local: string[] = []
260
- const external: string[] = []
261
- for (const match of source.matchAll(/^\s*require_relative\s+['"]([^'"]+)['"]/gm)) {
262
- const path = match[1]
263
- if (path !== undefined) local.push(path)
264
- }
265
- for (const match of source.matchAll(/^\s*require\s+['"]([^'"]+)['"]/gm)) {
266
- const path = match[1]
267
- if (path !== undefined) external.push(path)
268
- }
269
- return { local, external }
270
- },
271
- }
272
-
273
- const LUA: Language = {
274
- name: 'lua',
275
- extensions: ['.lua'],
276
- granularity: 'file',
277
- extract(text) {
278
- const source = stripHashComments(stripCComments(text))
279
- const local: string[] = []
280
- const external: string[] = []
281
- for (const match of source.matchAll(/require\s*\(?\s*['"]([^'"]+)['"]/g)) {
282
- const path = match[1]
283
- if (path === undefined) continue
284
- if (path.startsWith('.')) local.push(path)
285
- else external.push(path)
286
- }
287
- return { local, external }
288
- },
289
- }
290
-
291
- const ZIG: Language = {
292
- name: 'zig',
293
- extensions: ['.zig'],
294
- granularity: 'file',
295
- extract(text) {
296
- const local: string[] = []
297
- const external: string[] = []
298
- for (const match of stripCComments(text).matchAll(/@import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
299
- const specifier = match[1]
300
- if (specifier === undefined) continue
301
- // `@import` always names a file in the tree, so there is no bare form to separate out.
302
- local.push(specifier)
303
- }
304
- return { local, external }
305
- },
306
- }
307
-
308
- const DART: Language = {
309
- name: 'dart',
310
- extensions: ['.dart'],
311
- granularity: 'file',
312
- extract(text) {
313
- const source = stripCComments(text)
314
- const local: string[] = []
315
- const external: string[] = []
316
- for (const match of source.matchAll(/^\s*(?:import|export|part)\s+['"]([^'"]+)['"]/gm)) {
317
- const path = match[1]
318
- if (path === undefined) continue
319
- if (path.startsWith('dart:') || path.startsWith('package:')) external.push(path)
320
- else local.push(path)
321
- }
322
- return { local, external }
323
- },
324
- }
325
-
326
- const ELIXIR: Language = {
327
- name: 'elixir',
328
- extensions: ['.ex', '.exs'],
329
- granularity: 'file',
330
- extract(text) {
331
- const source = stripHashComments(text)
332
- const local: string[] = []
333
- const external: string[] = []
334
- for (const match of source.matchAll(/^\s*(?:alias|import|use)\s+([A-Z][\w.]*)/gm)) {
335
- const name = match[1]
336
- if (name === undefined) continue
337
- local.push('mod:' + name)
338
- }
339
- return { local, external }
340
- },
341
- }
342
-
343
- const SWIFT: Language = {
344
- name: 'swift',
345
- extensions: ['.swift'],
346
- granularity: 'package',
347
- extract(text) {
348
- const source = stripCComments(text)
349
- const local: string[] = []
350
- const external: string[] = []
351
- for (const match of source.matchAll(/^\s*import\s+(?:\w+\s+)?([\w.]+)/gm)) {
352
- const name = match[1]
353
- if (name === undefined) continue
354
- // A module name is not a path, and the module-to-file map is a build setting this scan does not
355
- // read, so a Swift import is reported as external instead of being guessed into an edge.
356
- external.push(name)
357
- }
358
- return { local, external }
359
- },
360
- }
361
-
362
- /** Every language this scan understands, by extension. */
363
- export const LANGUAGES: Language[] = [JS_LIKE, C_LIKE, PYTHON, GO, RUST, JAVA_LIKE, CSHARP, PHP, RUBY, LUA, ZIG, DART, ELIXIR, SWIFT]
364
-
365
- const BY_EXTENSION = new Map<string, Language>(LANGUAGES.flatMap((language) => language.extensions.map((extension) => [extension, language] as const)))
366
-
367
- /** Which language a file belongs to, if any. */
368
- export function languageOf(path: string): Language | undefined {
369
- return BY_EXTENSION.get(extname(path).toLowerCase())
370
- }
371
-
372
- // ------------------------------------------------------------------------------------------ resolution --
373
-
374
- interface Index {
375
- /** Every scanned file id. */
376
- files: Set<string>
377
- /** Directory ids, so a package language can resolve a package name to a directory. */
378
- directories: string[]
379
- /** Directory ids by their dotted form (`a/b` for both `a.b` and `a/b`). */
380
- byDotted: Map<string, string>
381
- }
382
-
383
- const tryFiles = (base: string, extensions: string[], index: Index): string | undefined => {
384
- const candidates = [base]
385
- for (const extension of extensions) candidates.push(base + extension)
386
- for (const extension of extensions) candidates.push(base + '/index' + extension)
387
- return candidates.find((candidate) => index.files.has(candidate))
388
- }
389
-
390
- /** Resolve one specifier to a module id, following the language's own rules. */
391
- function resolveSpecifier(language: Language, fromId: string, specifier: string, index: Index): string | undefined {
392
- if (language.granularity === 'package') {
393
- // A package language names a package, not a file: strip the member and match a directory.
394
- // A Java import separates with dots and a Go import with slashes, and a Go module path contains
395
- // both, so the specifier is split on either and the pieces are matched from both ends.
396
- const dotted = specifier.replaceAll('\\', '/').split(/[/.]/).filter(Boolean)
397
- // Two shapes meet here. A Java or C# import carries the package *root* and then the member, so the
398
- // directory is a prefix of the specifier. A Go import carries the module path and then the package,
399
- // so the directory is a suffix. Both are tried, exact package names first.
400
- const candidates: string[] = []
401
- for (let end = dotted.length; end > 0; end--) candidates.push(dotted.slice(0, end).join('/'))
402
- for (let start = 1; start < dotted.length; start++) candidates.push(dotted.slice(start).join('/'))
403
- for (const candidate of candidates) {
404
- // The index keys a directory by its dotted form, which is what a Java or C# specifier already is.
405
- const hit = index.byDotted.get(candidate.replaceAll('/', '.'))
406
- if (hit !== undefined) return hit
407
- const tail = index.directories.find((directory) => directory === candidate || directory.endsWith('/' + candidate))
408
- if (tail !== undefined) return tail
409
- }
410
- return undefined
411
- }
412
- if (language.name === 'python') {
413
- if (specifier.startsWith('.')) {
414
- // `.x` is the current package, `..x` is one level up; the dot count is how many levels to climb.
415
- const dots = /^\.+/.exec(specifier)?.[0].length ?? 1
416
- let base = dirOf(fromId)
417
- for (let level = 1; level < dots; level++) base = dirOf(base)
418
- return tryFiles(normalise([base, specifier.slice(dots).replaceAll('.', '/')]), ['.py', '.pyi'], index)
419
- }
420
- return tryFiles(specifier.replaceAll('.', '/'), ['.py', '.pyi'], index)
421
- }
422
- if (language.name === 'rust') {
423
- if (specifier.startsWith('mod:')) {
424
- const name = specifier.slice(4)
425
- return tryFiles(normalise([dirOf(fromId), name]), ['.rs'], index) ?? tryFiles(normalise([dirOf(fromId), name, 'mod']), ['.rs'], index)
426
- }
427
- const [root, path] = specifier.split('::')
428
- const segments = (path ?? '').split('::').filter(Boolean)
429
- const base = root === 'crate' ? '' : dirOf(fromId)
430
- // A use path names a module or an item inside it; try the longest prefix that exists.
431
- for (let length = segments.length; length > 0; length--) {
432
- const hit = tryFiles(normalise([base, segments.slice(0, length).join('/')]), ['.rs'], index)
433
- if (hit !== undefined) return hit
434
- }
435
- return undefined
436
- }
437
- if (language.name === 'elixir') {
438
- const name = specifier.startsWith('mod:') ? specifier.slice(4) : specifier
439
- const candidate = name.split('.').map((part) => part.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()).join('/')
440
- return tryFiles(candidate, ['.ex', '.exs'], index)
441
- }
442
- if (language.name === 'swift') {
443
- return index.byDotted.get(specifier.toLowerCase())
444
- }
445
- if (specifier.startsWith('/')) return undefined
446
- const base = joinPath(fromId, specifier)
447
- const extension = extname(base)
448
- const stem = extension === '' ? base : base.slice(0, -extension.length)
449
- // A specifier may carry an extension the language does not store (TypeScript writes `./x.js` for a
450
- // `.ts` source), so the stem is tried against the language's own extensions as well.
451
- const candidates = extension === ''
452
- ? [base, ...language.extensions.map((entry) => base + entry), ...language.extensions.map((entry) => base + '/index' + entry)]
453
- : [base, ...language.extensions.map((entry) => stem + entry)]
454
- return candidates.find((candidate) => index.files.has(candidate))
455
- }
456
-
457
- // ------------------------------------------------------------------------------------------------ scan --
458
-
459
- export function scanDependencyGraph(root: unknown): ScannedGraph {
460
- if (typeof root !== 'string' || root.trim() === '') {
461
- throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'repoPath must be a non-empty path to a directory or a single source file.')
462
- }
463
- const absolute = resolve(root)
464
- let stat
465
- try {
466
- stat = statSync(absolute)
467
- } catch (error) {
468
- const reason = error instanceof Error ? error.message : String(error)
469
- 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.`)
470
- }
471
- if (stat.isFile()) {
472
- const id = relative(resolve('/'), absolute).slice(1).replaceAll('\\', '/')
473
- return { nodes: [id], edges: [], filesRead: 1, unresolved: [], packages: [], languages: [] }
474
- }
475
- if (!stat.isDirectory()) {
476
- throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'repoPath must be a directory or a source file.')
477
- }
478
-
479
- const files: string[] = []
480
- const contents = new Map<string, string>()
481
- const byLanguage = new Map<string, number>()
482
- const walk = (directory: string): void => {
483
- if (files.length >= LIMITS.files) return
484
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
485
- if (files.length >= LIMITS.files) return
486
- if (entry.isSymbolicLink()) continue
487
- const full = resolve(directory, entry.name)
488
- if (entry.isDirectory()) {
489
- if (SKIP.has(entry.name)) continue
490
- walk(full)
491
- continue
492
- }
493
- if (!entry.isFile()) continue
494
- const language = languageOf(entry.name)
495
- if (language === undefined) continue
496
- const id = relative(absolute, full).replaceAll('\\', '/')
497
- let text
498
- try {
499
- if (statSync(full).size > LIMITS.bytes) continue
500
- text = readFileSync(full, 'utf8')
501
- } catch {
502
- continue
503
- }
504
- files.push(id)
505
- contents.set(id, text)
506
- byLanguage.set(language.name, (byLanguage.get(language.name) ?? 0) + 1)
507
- }
508
- }
509
- walk(absolute)
510
- files.sort()
511
-
512
- const fileSet = new Set(files)
513
- const directories = [...new Set(files.map((id) => dirOf(id)).filter((id) => id !== ''))].sort()
514
- const byDotted = new Map<string, string>()
515
- for (const directory of directories) byDotted.set(directory.replaceAll('/', '.'), directory)
516
- const index: Index = { files: fileSet, directories, byDotted }
517
-
518
- const edges: ScannedEdge[] = []
519
- const seen = new Set<string>()
520
- const unresolved: string[] = []
521
- const packages = new Set<string>()
522
- const nodeSet = new Set<string>()
523
-
524
- for (const id of files) {
525
- const language = languageOf(id) as Language
526
- const { local, external } = language.extract(contents.get(id) ?? '')
527
- const nodeOf = (fileId: string): string => (language.granularity === 'package' ? (dirOf(fileId) === '' ? '.' : dirOf(fileId)) : fileId)
528
- const from = nodeOf(id)
529
- nodeSet.add(from)
530
- for (const specifier of local) {
531
- const target = resolveSpecifier(language, id, specifier, index)
532
- if (target === undefined) {
533
- // A package language names modules by a path this tree may not contain. That is an external
534
- // dependency, not a broken reference, so it is counted rather than reported as unresolved.
535
- if (language.granularity === 'package') {
536
- const name = specifier.split(/[/.]/).filter(Boolean)[0]
537
- if (name !== undefined) packages.add(name)
538
- } else {
539
- unresolved.push(`${id} -> ${specifier}`)
540
- }
541
- continue
542
- }
543
- // A package resolver already answers with a directory; only a file resolver answers with a file.
544
- const to = target
545
- nodeSet.add(to)
546
- if (to === from) continue
547
- const key = from + '\u0000' + to
548
- if (seen.has(key)) continue
549
- seen.add(key)
550
- edges.push({ from, to })
551
- }
552
- for (const specifier of external) {
553
- const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split(/[/.:]/)[0]
554
- if (name !== undefined && name !== '') packages.add(name)
555
- }
556
- }
557
-
558
- const languages = [...byLanguage.entries()]
559
- .map(([name, count]) => ({ language: name, files: count, granularity: (LANGUAGES.find((language) => language.name === name)?.granularity ?? 'file') as LangGranularity }))
560
- .sort((a, b) => b.files - a.files)
561
- edges.sort((a, b) => (a.from === b.from ? a.to.localeCompare(b.to) : a.from.localeCompare(b.to)))
562
- unresolved.sort()
563
- return { nodes: [...nodeSet].sort(), edges, filesRead: files.length, unresolved, packages: [...packages].sort(), languages }
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
+ }