dsh-model-everything 0.1.0 → 0.3.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.
Files changed (62) hide show
  1. package/README.en-US.md +102 -81
  2. package/README.md +88 -76
  3. package/lib/automaton-tool.js +69 -0
  4. package/lib/automaton.js +327 -0
  5. package/lib/compose-tool.js +11 -2
  6. package/lib/concurrency-tool.js +6 -1
  7. package/lib/data-engine.js +1 -1
  8. package/lib/data-tool.js +18 -5
  9. package/lib/design-tool.js +21 -8
  10. package/lib/edit-tool.js +111 -0
  11. package/lib/editor.js +293 -0
  12. package/lib/engine.js +8 -1
  13. package/lib/export-tool.js +6 -1
  14. package/lib/index.js +4 -0
  15. package/lib/model-file.js +48 -0
  16. package/lib/presentation.js +81 -0
  17. package/lib/report-diff-tool.js +6 -1
  18. package/lib/report-view.js +92 -15
  19. package/lib/routing.js +85 -0
  20. package/lib/scan.js +547 -0
  21. package/lib/structure-tool.js +34 -1
  22. package/lib/structure.js +28 -3
  23. package/lib/tool.js +19 -4
  24. package/lib/types/automaton-tool.d.ts +2 -0
  25. package/lib/types/automaton.d.ts +57 -0
  26. package/lib/types/edit-tool.d.ts +2 -0
  27. package/lib/types/editor.d.ts +46 -0
  28. package/lib/types/engine.d.ts +1 -0
  29. package/lib/types/model-file.d.ts +7 -0
  30. package/lib/types/presentation.d.ts +59 -0
  31. package/lib/types/report-view.d.ts +8 -1
  32. package/lib/types/routing.d.ts +31 -0
  33. package/lib/types/scan.d.ts +37 -0
  34. package/lib/types/structure.d.ts +16 -0
  35. package/lib/uml-tool.js +16 -6
  36. package/package.json +8 -3
  37. package/skills/model-everything/SKILL.md +51 -79
  38. package/skills/model-everything/references/automata.md +102 -0
  39. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -60
  40. package/skills/model-everything-report-reading/SKILL.md +137 -137
  41. package/src/automaton-tool.ts +73 -0
  42. package/src/automaton.ts +375 -0
  43. package/src/compose-tool.ts +13 -3
  44. package/src/concurrency-tool.ts +7 -1
  45. package/src/data-engine.ts +1 -1
  46. package/src/data-tool.ts +19 -5
  47. package/src/design-tool.ts +74 -60
  48. package/src/edit-tool.ts +119 -0
  49. package/src/editor.ts +297 -0
  50. package/src/engine.ts +8 -1
  51. package/src/export-tool.ts +87 -81
  52. package/src/index.ts +4 -0
  53. package/src/model-file.ts +49 -0
  54. package/src/presentation.ts +125 -0
  55. package/src/report-diff-tool.ts +7 -1
  56. package/src/report-view.ts +534 -451
  57. package/src/routing.ts +96 -0
  58. package/src/scan.ts +564 -0
  59. package/src/structure-tool.ts +33 -1
  60. package/src/structure.ts +37 -3
  61. package/src/tool.ts +19 -4
  62. package/src/uml-tool.ts +240 -230
package/src/routing.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Which tool owns which question, in one table.
3
+ *
4
+ * Two consumers read this, and they used to read two different things: the result a model gets
5
+ * (`nextSteps` naming a sibling) and the toolset map a human reads (hand-written prose). That is how the
6
+ * map came to say "about ten thousand characters" while the descriptions measured twenty-nine: a fact
7
+ * stated twice drifts, and nothing fails when it does.
8
+ *
9
+ * So the table states the routing once. `routeFor` hands it to the card a UI draws; the toolset map
10
+ * generator reads the same table and refuses to write a map for a tool that is missing from it.
11
+ *
12
+ * What it is not: a decision. The tool a request should use is chosen by the model from the descriptions.
13
+ * This table says what each tool owns and which neighbour takes the case just outside that line, so a
14
+ * report can redirect without a reader having to remember the roster.
15
+ */
16
+
17
+ export interface Route {
18
+ /** What this tool decides, in the words its report already uses. */
19
+ owns: string
20
+ /** The case just outside the line, and the tool that owns it. */
21
+ notFor: { when: string; tool: string }
22
+ }
23
+
24
+ export const ROUTES: Record<string, Route> = {
25
+ model_verify: {
26
+ owns: 'one machine: reachable states, invariants, deadlock, and a before/after regression',
27
+ notFor: { when: 'two or more machines must handshake or share a location', tool: 'model_compose_verify' },
28
+ },
29
+ model_compose_verify: {
30
+ owns: 'several machines run together: rendezvous, shared locations, lost updates',
31
+ notFor: { when: 'the question is about one machine on its own', tool: 'model_verify' },
32
+ },
33
+ model_automaton_verify: {
34
+ owns: 'a finite-word language, and its relation to another automaton',
35
+ notFor: { when: 'the question is reachability or deadlock inside one state machine', tool: 'model_verify' },
36
+ },
37
+ model_edit: {
38
+ owns: 'one editing round on a model file, answered with the structural diff and the change in findings',
39
+ notFor: { when: 'the edit is done and the model has to be decided again', tool: 'model_verify' },
40
+ },
41
+ model_datamodel_verify: {
42
+ owns: 'entities, keys, migrations, and whether the data survived them',
43
+ notFor: { when: 'the question is about behaviour over time rather than stored state', tool: 'model_verify' },
44
+ },
45
+ model_design_review: {
46
+ owns: "a design's own declarations: layering, responsibility, substitutability, interface width",
47
+ notFor: { when: 'the question is a fact about the graph - does this edge exist', tool: 'uml_structure_verify' },
48
+ },
49
+ model_interleaving_scan: {
50
+ owns: 'mining a document or plan for concurrency claims, and routing each to the analysis that decides it',
51
+ notFor: { when: 'the protocol itself has to be decided', tool: 'model_compose_verify' },
52
+ },
53
+ model_export: {
54
+ owns: 'a model translated into UPPAAL, TLA+, PRISM or SPIN for a property this plugin does not decide',
55
+ notFor: { when: 'the property can be decided here', tool: 'model_verify' },
56
+ },
57
+ report_diff: {
58
+ owns: 'what changed between two reports of the same family',
59
+ notFor: { when: 'the current report does not exist yet', tool: 'model_verify' },
60
+ },
61
+ uml_render: {
62
+ owns: 'drawing a model in the notation that can carry its family',
63
+ notFor: { when: 'the question is whether the modelling is sound', tool: 'uml_review' },
64
+ },
65
+ uml_parse: {
66
+ owns: 'reading a diagram back into the model its family declares',
67
+ notFor: { when: 'the question is whether the diagram says something sound', tool: 'uml_review' },
68
+ },
69
+ uml_review: {
70
+ owns: 'auditing the modelling: reachability, ambiguity, round-trip fidelity, family-specific defects',
71
+ notFor: { when: 'the claim is about behaviour rather than the drawing', tool: 'model_verify' },
72
+ },
73
+ uml_explain_labels: {
74
+ owns: 'the label spellings a notation accepts and the directives that pin the rest',
75
+ notFor: { when: 'a diagram has to be read rather than explained', tool: 'uml_parse' },
76
+ },
77
+ uml_structure_verify: {
78
+ owns: 'a dependency graph as a graph - cycles, layers, dangling edges - from a drawing or a source tree',
79
+ notFor: { when: 'the question is a rule the design declared for itself, not an edge that exists', tool: 'model_design_review' },
80
+ },
81
+ }
82
+
83
+ /** The route for a tool, or undefined when the tool has none. */
84
+ export function routeFor(tool: string): Route | undefined {
85
+ return ROUTES[tool]
86
+ }
87
+
88
+ /** Every tool a route names, for a caller that has to check the table against the registry. */
89
+ export function routedTools(): string[] {
90
+ return Object.keys(ROUTES)
91
+ }
92
+
93
+ /** The tools named as the owner of an adjacent case. */
94
+ export function siblingTools(): string[] {
95
+ return [...new Set(Object.values(ROUTES).map((route) => route.notFor.tool))].sort()
96
+ }
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
+ }