dsh-model-everything 0.2.0 → 0.4.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 +104 -76
- package/README.md +84 -71
- package/lib/automaton-tool.js +70 -0
- package/lib/automaton.js +328 -0
- package/lib/compose-tool.js +7 -0
- package/lib/concurrency-tool.js +7 -0
- package/lib/data-tool.js +7 -0
- package/lib/design-tool.js +8 -1
- package/lib/edit-tool.js +113 -0
- package/lib/editor.js +293 -0
- package/lib/engine.js +7 -0
- package/lib/export-tool.js +8 -0
- package/lib/index.js +4 -0
- package/lib/presentation.js +309 -0
- package/lib/report-diff-tool.js +7 -0
- package/lib/report-view.js +62 -10
- package/lib/routing.js +85 -0
- package/lib/scan.js +547 -0
- package/lib/structure-tool.js +35 -1
- package/lib/structure.js +28 -3
- package/lib/tool.js +7 -0
- package/lib/types/automaton-tool.d.ts +2 -0
- package/lib/types/automaton.d.ts +57 -0
- package/lib/types/edit-tool.d.ts +2 -0
- package/lib/types/editor.d.ts +46 -0
- package/lib/types/engine.d.ts +1 -0
- package/lib/types/presentation.d.ts +164 -0
- package/lib/types/routing.d.ts +31 -0
- package/lib/types/scan.d.ts +37 -0
- package/lib/types/structure.d.ts +16 -0
- package/lib/uml-tool.js +21 -6
- package/package.json +6 -3
- package/skills/model-everything/SKILL.md +6 -2
- package/skills/model-everything/references/automata.md +102 -0
- package/src/automaton-tool.ts +74 -0
- package/src/automaton.ts +376 -0
- package/src/compose-tool.ts +13 -5
- package/src/concurrency-tool.ts +8 -0
- package/src/data-tool.ts +8 -0
- package/src/design-tool.ts +76 -68
- package/src/edit-tool.ts +121 -0
- package/src/editor.ts +297 -0
- package/src/engine.ts +7 -0
- package/src/export-tool.ts +90 -81
- package/src/index.ts +4 -0
- package/src/presentation.ts +365 -0
- package/src/report-diff-tool.ts +8 -0
- package/src/report-view.ts +53 -2
- package/src/routing.ts +96 -0
- package/src/scan.ts +564 -0
- package/src/structure-tool.ts +34 -1
- package/src/structure.ts +37 -3
- package/src/tool.ts +7 -0
- package/src/uml-tool.ts +245 -230
package/src/scan.ts
ADDED
|
@@ -0,0 +1,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
|
+
/** 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
|
+
}
|
package/src/structure-tool.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
2
2
|
import type { JsonValue } from './json-value.js'
|
|
3
3
|
import { REPORT_SCHEMAS } from './engine.js'
|
|
4
4
|
import { reviewStructure } from './structure.js'
|
|
5
|
+
import { scanDependencyGraph } from './scan.js'
|
|
5
6
|
import { reviewGranularity, type GranularityDiagram } from './granularity.js'
|
|
6
7
|
import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js'
|
|
7
8
|
import { renderReportText } from './report-view.js'
|
|
9
|
+
import { reportCallView, reportCard, structureResultView } from './presentation.js'
|
|
8
10
|
|
|
9
11
|
export const STRUCTURE_TOOL_NAME = 'uml_structure_verify'
|
|
10
12
|
|
|
@@ -19,7 +21,7 @@ export const STRUCTURE_TOOL_NAME = 'uml_structure_verify'
|
|
|
19
21
|
export const umlStructureTool = defineTool({
|
|
20
22
|
name: STRUCTURE_TOOL_NAME,
|
|
21
23
|
description:
|
|
22
|
-
'Audit a structure diagram as a dependency graph. Takes PlantUML component, package, class or deployment text, or a Mermaid class diagram. Reports UML020 isolated node, UML021 dangling reference, UML022 cycle (shortest path), UML023 disallowed edge and UML024 layer violation against a matrix, UML025 missing required edge, and UML026 when no node is declared. The matrix is {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Ids and members accept globs (`*`, `?`). Deny beats allow, and layers run top-first, so downward edges pass and upward ones are UML024. With a matrix, every edge appears in `edgeVerdicts` with the rules it matched. Pass `diagrams` instead of `diagram` to check several levels at once: each child must refine its parent.
|
|
24
|
+
'Audit a structure diagram as a dependency graph. Takes PlantUML component, package, class or deployment text, or a Mermaid class diagram - or `repoPath`, a source tree, in which case it builds the graph from the import constructs the source itself uses and no drawing is needed. Reports UML020 isolated node, UML021 dangling reference, UML022 cycle (shortest path), UML023 disallowed edge and UML024 layer violation against a matrix, UML025 missing required edge, and UML026 when no node is declared. The matrix is {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Ids and members accept globs (`*`, `?`). Deny beats allow, and layers run top-first, so downward edges pass and upward ones are UML024. With a matrix, every edge appears in `edgeVerdicts` with the rules it matched. Pass `diagrams` instead of `diagram` to check several levels at once: each child must refine its parent. It answers **what the graph is**: which edges exist, which node has no edge, which cycle, and whether an edge breaks a matrix you supplied. It does not judge whether the design meant to allow those edges - `model_design_review` does that against the rules the design declares for itself. When the question is whether an import exists from one layer to another, this answers it, from the code. Read `verdict`, not `ok`. For state or activity diagrams use `uml` and `model_verify`.',
|
|
23
25
|
parameters: {
|
|
24
26
|
diagram: {
|
|
25
27
|
type: 'string',
|
|
@@ -34,6 +36,10 @@ export const umlStructureTool = defineTool({
|
|
|
34
36
|
enum: ['auto', 'plantuml', 'mermaid'],
|
|
35
37
|
description: 'Diagram language. Default auto (detected from the text).',
|
|
36
38
|
},
|
|
39
|
+
repoPath: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'Directory or single source file to scan instead of drawing the diagram yourself. Reads the import construct of C/C++, Python, TypeScript/JavaScript, Go, Rust, Java/Kotlin/Scala, C#, PHP, Ruby, Lua, Zig, Dart and Elixir, building a file graph for file languages and a package graph for package languages (Go, Java, C#). The graph is reviewed by the same checks; `diagram` and `diagrams` are then ignored.',
|
|
42
|
+
},
|
|
37
43
|
matrix: {
|
|
38
44
|
type: 'json',
|
|
39
45
|
description: 'Optional dependency matrix: {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Every edge is judged against it and the matched rule ids are reported.',
|
|
@@ -52,7 +58,13 @@ export const umlStructureTool = defineTool({
|
|
|
52
58
|
render(args, value) {
|
|
53
59
|
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
54
60
|
},
|
|
61
|
+
// The card a UI shows, separated from the text the model reads.
|
|
62
|
+
presentationMeta(_args, value) {
|
|
63
|
+
return reportCard(value, STRUCTURE_TOOL_NAME) as unknown as JsonValue
|
|
64
|
+
},
|
|
55
65
|
},
|
|
66
|
+
presentCall: reportCallView(STRUCTURE_TOOL_NAME),
|
|
67
|
+
presentResult: structureResultView,
|
|
56
68
|
timeoutMs: 10_000,
|
|
57
69
|
isConcurrencySafe: () => true,
|
|
58
70
|
async execute(args) {
|
|
@@ -81,6 +93,27 @@ export const umlStructureTool = defineTool({
|
|
|
81
93
|
return optionRefusal(error, 'STRUCTURE_OPTION_INVALID') as unknown as JsonValue
|
|
82
94
|
}
|
|
83
95
|
}
|
|
96
|
+
if (args.repoPath !== undefined) {
|
|
97
|
+
try {
|
|
98
|
+
const scanned = scanDependencyGraph(args.repoPath)
|
|
99
|
+
// A source module is a declared node that depends on others: not a container, and it has a
|
|
100
|
+
// line only when it came from a drawing, so the field stays absent.
|
|
101
|
+
const graph = {
|
|
102
|
+
notation: 'plantuml' as const,
|
|
103
|
+
nodes: scanned.nodes.map((id) => ({ id, label: id, kind: 'component' as const, container: false, declared: true })),
|
|
104
|
+
edges: scanned.edges.map((edge) => ({ from: edge.from, to: edge.to, line: 0 })),
|
|
105
|
+
warnings: [],
|
|
106
|
+
}
|
|
107
|
+
return reviewStructure({
|
|
108
|
+
diagram: '',
|
|
109
|
+
graph,
|
|
110
|
+
scan: { files: scanned.filesRead, unresolved: scanned.unresolved.length, packages: scanned.packages.length, languages: scanned.languages.map((entry) => entry.language) },
|
|
111
|
+
...(args.matrix === undefined ? {} : { matrix: args.matrix }),
|
|
112
|
+
}) as unknown as JsonValue
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return optionRefusal(error, 'STRUCTURE_OPTION_INVALID') as unknown as JsonValue
|
|
115
|
+
}
|
|
116
|
+
}
|
|
84
117
|
if (args.diagram === undefined) {
|
|
85
118
|
return { ok: false, ran: false, verdict: 'fail', verdictReason: 'the tool did not run: pass `diagram` (one structure diagram) or `diagrams` (several levels with parents)', schema: REPORT_SCHEMAS.structure, errorCode: 'UML_INPUT', error: 'structure review needs `diagram` or `diagrams`' } as unknown as JsonValue
|
|
86
119
|
}
|