dsh-model-everything 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.en-US.md +63 -19
  2. package/README.md +373 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +1273 -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/collapse.js +163 -0
  14. package/lib/render/diff.js +138 -0
  15. package/lib/render/from-mermaid.js +211 -0
  16. package/lib/render/from-plantuml.js +158 -0
  17. package/lib/render/index.js +42 -0
  18. package/lib/render/layout.js +52 -0
  19. package/lib/render/model.js +17 -0
  20. package/lib/render/paint.js +194 -0
  21. package/lib/render/palette.js +127 -0
  22. package/lib/render/place.js +210 -0
  23. package/lib/render/svg.js +107 -0
  24. package/lib/render/viewmodel.js +126 -0
  25. package/lib/render/write.js +14 -0
  26. package/lib/scan.js +248 -8
  27. package/lib/structure-tool.js +2 -2
  28. package/lib/types/client.d.ts +83 -0
  29. package/lib/types/diagram-diff-tool.d.ts +8 -0
  30. package/lib/types/diagram-render-tool.d.ts +8 -0
  31. package/lib/types/diagram-review-tool.d.ts +8 -0
  32. package/lib/types/diagram.d.ts +34 -0
  33. package/lib/types/presentation.d.ts +7 -0
  34. package/lib/types/render/budget.d.ts +55 -0
  35. package/lib/types/render/collapse.d.ts +23 -0
  36. package/lib/types/render/diff.d.ts +44 -0
  37. package/lib/types/render/from-mermaid.d.ts +27 -0
  38. package/lib/types/render/from-plantuml.d.ts +27 -0
  39. package/lib/types/render/index.d.ts +38 -0
  40. package/lib/types/render/layout.d.ts +22 -0
  41. package/lib/types/render/model.d.ts +56 -0
  42. package/lib/types/render/paint.d.ts +94 -0
  43. package/lib/types/render/palette.d.ts +41 -0
  44. package/lib/types/render/place.d.ts +39 -0
  45. package/lib/types/render/svg.d.ts +19 -0
  46. package/lib/types/render/viewmodel.d.ts +20 -0
  47. package/lib/types/render/write.d.ts +14 -0
  48. package/lib/types/scan.d.ts +10 -2
  49. package/lib/types/structure.d.ts +1 -0
  50. package/package.json +148 -131
  51. package/skills/model-everything/SKILL.md +454 -454
  52. package/skills/model-everything/references/architecture-domains.md +137 -0
  53. package/skills/model-everything/references/architecture-views.md +119 -0
  54. package/skills/model-everything-diagram/SKILL.md +82 -0
  55. package/src/client.ts +307 -0
  56. package/src/diagram-diff-tool.ts +94 -0
  57. package/src/diagram-render-tool.ts +108 -0
  58. package/src/diagram-review-tool.ts +109 -0
  59. package/src/diagram.ts +61 -0
  60. package/src/gate.ts +14 -12
  61. package/src/presentation.ts +12 -0
  62. package/src/render/budget.ts +135 -0
  63. package/src/render/collapse.ts +155 -0
  64. package/src/render/diff.ts +162 -0
  65. package/src/render/from-mermaid.ts +236 -0
  66. package/src/render/from-plantuml.ts +178 -0
  67. package/src/render/index.ts +73 -0
  68. package/src/render/layout.ts +62 -0
  69. package/src/render/model.ts +67 -0
  70. package/src/render/paint.ts +290 -0
  71. package/src/render/palette.ts +164 -0
  72. package/src/render/place.ts +234 -0
  73. package/src/render/svg.ts +127 -0
  74. package/src/render/viewmodel.ts +136 -0
  75. package/src/render/write.ts +17 -0
  76. package/src/scan.ts +807 -564
  77. package/src/structure-tool.ts +2 -2
  78. package/src/structure.ts +1 -1
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Shapes to SVG markup.
3
+ *
4
+ * The one module that knows tags, so it is also the one place where a shape can silently disappear if a
5
+ * writer forgets a case: the writer table has an entry per shape kind, and the caller counts the drops
6
+ * instead of hiding them.
7
+ *
8
+ * Three containers read this output - an inline card, a full-screen view, and a sidebar tab - so the markup
9
+ * carries no size of its own beyond its `viewBox`: the container decides how large the drawing is, and the
10
+ * same string serves a thumbnail and a whiteboard.
11
+ *
12
+ * Pure, and the output is asserted as text: escaping, geometry, arrowheads, wrapped captions.
13
+ */
14
+ /** One decimal place is enough at any zoom, and it keeps the markup readable. */
15
+ const n = (value) => {
16
+ const rounded = Math.round(value * 10) / 10;
17
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
18
+ };
19
+ const escape = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
20
+ const writers = {
21
+ // A boundary is a dashed rectangle with its name in the top-left corner, drawn under everything else.
22
+ group: (shape, colours) => {
23
+ const stroke = colours[shape.colour];
24
+ // The id travels in the markup so a container can fold a boundary by delegation, without re-deriving which
25
+ // group a click landed on from its position or its label.
26
+ return ('<g data-group-id="' + escape(shape.id) + '" style="cursor:pointer">' +
27
+ '<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
28
+ '" rx="8" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-dasharray="4 3" />' +
29
+ '<text x="' + n(shape.x + 6) + '" y="' + n(shape.y + 12) + '" font-size="10" fill="' + stroke + '">' + escape(shape.label) + '</text>' +
30
+ '</g>');
31
+ },
32
+ box: (shape, colours) => {
33
+ const stroke = colours[shape.colour];
34
+ const width = shape.initial ? 2.5 : 1;
35
+ return ('<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
36
+ '" rx="6" fill="' + colours.surface + '" stroke="' + stroke + '" stroke-width="' + n(width) + '" />' +
37
+ '<text x="' + n(shape.x + shape.width / 2) + '" y="' + n(shape.y + shape.height / 2 + 4) +
38
+ '" font-size="11" text-anchor="middle" fill="' + stroke + '">' + escape(shape.label) + '</text>');
39
+ },
40
+ link: (shape, colours) => {
41
+ const stroke = colours[shape.colour];
42
+ const arrow = arrowHead(shape.x1, shape.y1, shape.x2, shape.y2, stroke);
43
+ return ('<line x1="' + n(shape.x1) + '" y1="' + n(shape.y1) + '" x2="' + n(shape.x2) + '" y2="' + n(shape.y2) +
44
+ '" stroke="' + stroke + '" stroke-width="1" />' + arrow + edgeLabel(shape.label, shape.labelX, shape.labelY, colours));
45
+ },
46
+ path: (shape, colours) => {
47
+ const stroke = colours[shape.colour];
48
+ const points = shape.points.map((point) => n(point.x) + ',' + n(point.y)).join(' ');
49
+ const last = shape.points[shape.points.length - 1];
50
+ const before = shape.points[shape.points.length - 2] ?? last;
51
+ return ('<polyline points="' + points + '" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-linejoin="round" />' +
52
+ arrowHead(before.x, before.y, last.x, last.y, stroke) + edgeLabel(shape.label, shape.labelX, shape.labelY, colours));
53
+ },
54
+ title: (shape, colours) => {
55
+ return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>';
56
+ },
57
+ caption: (shape, colours) => {
58
+ const colour = colours[shape.colour];
59
+ const lines = shape.lines
60
+ .map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
61
+ .join('');
62
+ return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>';
63
+ },
64
+ };
65
+ /** A small filled triangle at the end of a segment, pointing along it. */
66
+ function arrowHead(x1, y1, x2, y2, stroke) {
67
+ const length = Math.hypot(x2 - x1, y2 - y1);
68
+ if (length < 1)
69
+ return '';
70
+ const ux = (x2 - x1) / length;
71
+ const uy = (y2 - y1) / length;
72
+ const size = 7;
73
+ const half = 3.2;
74
+ const baseX = x2 - ux * size;
75
+ const baseY = y2 - uy * size;
76
+ const left = n(baseX - uy * half) + ',' + n(baseY + ux * half);
77
+ const right = n(baseX + uy * half) + ',' + n(baseY - ux * half);
78
+ return '<polygon points="' + n(x2) + ',' + n(y2) + ' ' + left + ' ' + right + '" fill="' + stroke + '" />';
79
+ }
80
+ /** An edge label, or nothing when the edge carries no event. */
81
+ function edgeLabel(label, x, y, colours) {
82
+ // Total on purpose: a malformed edge must lose its label, not the whole drawing.
83
+ if (typeof label !== 'string' || label === '')
84
+ return '';
85
+ return '<text x="' + n(x) + '" y="' + n(y) + '" font-size="10" fill="' + colours.muted + '">' + escape(label) + '</text>';
86
+ }
87
+ export function toSvg(canvas, colours, label) {
88
+ let dropped = 0;
89
+ const body = [];
90
+ for (const shape of canvas.shapes) {
91
+ const writer = writers[shape.kind];
92
+ if (writer === undefined) {
93
+ dropped += 1;
94
+ continue;
95
+ }
96
+ body.push(writer(shape, colours));
97
+ }
98
+ const header = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
99
+ '" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
100
+ (label === undefined ? '' : ' aria-label="' + escape(label) + '"') +
101
+ ' font-family="system-ui, -apple-system, Segoe UI, sans-serif">';
102
+ // The surface is part of the drawing, not the container's business: a page that paints its own layers - a
103
+ // wallpaper, a themed panel - would otherwise change the contrast of a picture it knows nothing about.
104
+ const background = '<rect x="0" y="0" width="' + n(canvas.width) + '" height="' + n(canvas.height) +
105
+ '" fill="' + colours.background + '" />';
106
+ return { markup: header + background + body.join('') + '</svg>', dropped };
107
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What a tool card shows, reduced to a drawing.
3
+ *
4
+ * The input is what the host hands a settled tool view: a phase and a block whose shape differs per phase.
5
+ * A settled call carries the paired call's arguments as a **lazy view** (`PartialArguments`) and the result
6
+ * as content blocks, so nothing here reads `block.args.model`: the view is asked, and the report text is
7
+ * parsed for the two facts a drawing needs. Every reader is total - an absent field removes a shape rather
8
+ * than throwing, because the same view renders during streaming.
9
+ *
10
+ * Pure. Everything decided here is a fact a test can assert: which nodes exist, which edges, why they are
11
+ * coloured the way they are.
12
+ */
13
+ const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
14
+ const array = (value) => (Array.isArray(value) ? value : []);
15
+ const text = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
16
+ /** The report text a settled call carries, joined: a card reads words, not blocks. */
17
+ function reportText(content) {
18
+ const parts = [];
19
+ for (const entry of array(content)) {
20
+ const block = record(entry);
21
+ const body = text(block?.text);
22
+ if (body !== undefined)
23
+ parts.push(body);
24
+ }
25
+ return parts.join(String.fromCharCode(10));
26
+ }
27
+ /** Two facts out of this plugin's own report format: the verdict, and the first error it named. */
28
+ function readReport(content) {
29
+ const body = reportText(content);
30
+ if (body === '')
31
+ return {};
32
+ const verdict = /verdict=(\w+)/.exec(body)?.[1];
33
+ const named = /\[error\/([A-Z0-9_]+)\]\s*(.*)/.exec(body);
34
+ if (named !== null)
35
+ return { verdict, code: named[1], message: named[2] };
36
+ const first = /first error:\s*([A-Z0-9_]+)/.exec(body);
37
+ return first === null ? { verdict } : { verdict, code: first[1] };
38
+ }
39
+ /**
40
+ * The `model` argument out of the host's lazy arguments view.
41
+ *
42
+ * A settled call exposes `value(key)`; a plain object (a test, or an older shape) is read directly. The
43
+ * view answers `undefined` until the field closes, which is exactly when a drawing should be attempted.
44
+ */
45
+ function readModel(args) {
46
+ const view = record(args);
47
+ if (view === undefined)
48
+ return undefined;
49
+ const read = view.value;
50
+ if (typeof read === 'function')
51
+ return record(read('model'));
52
+ return record(view.model);
53
+ }
54
+ /** The states of a model: a machine, or nothing when the call carried none. */
55
+ function machineOf(model) {
56
+ if (model === undefined)
57
+ return undefined;
58
+ const states = array(model.states).map(record).filter((entry) => entry !== undefined && typeof entry.id === 'string');
59
+ if (states.length === 0)
60
+ return undefined;
61
+ const edges = array(model.transitions)
62
+ .map(record)
63
+ .filter((entry) => entry !== undefined && typeof entry.from === 'string' && typeof entry.to === 'string')
64
+ .map((entry) => ({ from: String(entry.from), to: String(entry.to), label: text(entry.event) ?? '' }));
65
+ return {
66
+ nodes: states.map((state) => ({ id: String(state.id), role: 'normal' })),
67
+ edges,
68
+ ...(typeof model.init === 'string' ? { init: model.init } : {}),
69
+ };
70
+ }
71
+ /** The caption: the verdict, then the first error's code and message when the report named one. */
72
+ function captionOf(report) {
73
+ const verdict = report.verdict ?? 'no report yet';
74
+ if (report.code === undefined)
75
+ return verdict;
76
+ const message = report.message ?? '';
77
+ return verdict + ' — ' + report.code + (message === '' ? '' : ': ' + message);
78
+ }
79
+ /**
80
+ * Which nodes the report blames.
81
+ *
82
+ * A finding names a state inside its message as `"x"`; matching that is a heuristic, so it only ever *adds*
83
+ * emphasis and never decides the verdict.
84
+ */
85
+ function blame(nodes, report) {
86
+ if (report.message === undefined)
87
+ return nodes;
88
+ const message = report.message;
89
+ // Whole words only: a report does not quote the state, and a state called `do` must not match `done`.
90
+ const named = nodes
91
+ .filter((node) => {
92
+ const escaped = node.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ return new RegExp('(?<![A-Za-z0-9_])' + escaped + '(?![A-Za-z0-9_])').test(message);
94
+ })
95
+ .map((node) => node.id);
96
+ if (named.length === 0)
97
+ return nodes;
98
+ return nodes.map((node) => (named.includes(node.id) ? { ...node, role: 'error' } : node));
99
+ }
100
+ export function viewModel(input) {
101
+ const block = record(input.block);
102
+ const title = text(block?.name) ?? text(input.toolName) ?? 'tool';
103
+ const report = readReport(block?.content);
104
+ // A shape that still carries a parsed report object is read from there; it is what older callers pass.
105
+ const parsed = record(block?.result);
106
+ const findings = array(parsed?.findings).map(record);
107
+ const firstError = findings.find((finding) => finding?.severity === 'error');
108
+ const merged = {
109
+ verdict: text(parsed?.verdict) ?? report.verdict,
110
+ ...(report.code === undefined && typeof firstError?.code === 'string' ? { code: firstError.code } : { code: report.code }),
111
+ ...(report.message === undefined && typeof firstError?.message === 'string' ? { message: firstError.message } : { message: report.message }),
112
+ };
113
+ const caption = captionOf(merged);
114
+ const machine = machineOf(readModel(block?.args));
115
+ if (machine === undefined) {
116
+ return { kind: 'none', title, nodes: [], edges: [], caption };
117
+ }
118
+ return {
119
+ kind: 'machine',
120
+ title,
121
+ ...(machine.init === undefined ? {} : { init: machine.init }),
122
+ nodes: blame(machine.nodes, merged),
123
+ edges: machine.edges,
124
+ caption,
125
+ };
126
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Writing a drawing to disk: the CLI's job, kept out of the renderer.
3
+ *
4
+ * `render/index.ts` is bundled into the page as well as run in Node, so it may not import a filesystem;
5
+ * everything that touches one lives here, where only the host reaches it.
6
+ */
7
+ import { writeFileSync } from 'node:fs';
8
+ import { render } from './index.js';
9
+ /** Write the drawing where a document can link to it. Returns the path, the byte count and the render. */
10
+ export function writeSvg(input, path, overrides = {}, options = {}) {
11
+ const rendered = render(input, overrides, options);
12
+ writeFileSync(path, rendered.markup, 'utf8');
13
+ return { path, bytes: rendered.markup.length, rendered };
14
+ }
package/lib/scan.js CHANGED
@@ -18,6 +18,55 @@
18
18
  import { readFileSync, readdirSync, statSync } from 'node:fs';
19
19
  import { extname, relative, resolve } from 'node:path';
20
20
  import { OptionShapeError } from './option-shape.js';
21
+ /** `tsconfig.json` allows comments and trailing commas; this reads the subset it needs without a parser. */
22
+ function parseJsonc(text) {
23
+ const withoutComments = stripCComments(text);
24
+ const withoutTrailingCommas = withoutComments.replaceAll(/,\s*([}\]])/g, '$1');
25
+ try {
26
+ return JSON.parse(withoutTrailingCommas);
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ /**
33
+ * The aliases a TypeScript or JavaScript tree declares in its own `tsconfig.json`/`jsconfig.json`.
34
+ *
35
+ * Read rather than asked for: the project already states how it resolves `@/x`, and asking a caller to
36
+ * repeat it is how a scan silently loses half its edges.
37
+ */
38
+ function readAliases(root) {
39
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
40
+ let text;
41
+ try {
42
+ text = readFileSync(resolve(root, name), 'utf8');
43
+ }
44
+ catch {
45
+ continue;
46
+ }
47
+ const parsed = parseJsonc(text);
48
+ if (typeof parsed !== 'object' || parsed === null)
49
+ continue;
50
+ const options = parsed.compilerOptions;
51
+ if (typeof options !== 'object' || options === null)
52
+ continue;
53
+ const compiler = options;
54
+ const baseUrl = typeof compiler.baseUrl === 'string' ? compiler.baseUrl.replaceAll(String.fromCharCode(92), '/').replace(/^\.\//, '') : undefined;
55
+ const aliases = [];
56
+ if (typeof compiler.paths === 'object' && compiler.paths !== null) {
57
+ for (const [pattern, targets] of Object.entries(compiler.paths)) {
58
+ if (!Array.isArray(targets))
59
+ continue;
60
+ const cleaned = targets.filter((target) => typeof target === 'string').map((target) => (baseUrl === undefined ? target : normalise([baseUrl, target])));
61
+ if (cleaned.length === 0)
62
+ continue;
63
+ aliases.push({ prefix: pattern.replace(/\*$/, ''), targets: cleaned.map((target) => target.replace(/\*$/, '')) });
64
+ }
65
+ }
66
+ return { aliases, ...(baseUrl === undefined ? {} : { baseUrl }) };
67
+ }
68
+ return { aliases: [] };
69
+ }
21
70
  const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.cache', 'target', 'vendor', '__pycache__', '.venv', 'venv']);
22
71
  const LIMITS = { files: 5000, bytes: 2_000_000 };
23
72
  /** Strip comments so a commented-out include is not an edge. */
@@ -64,7 +113,14 @@ const JS_LIKE = {
64
113
  extract(text) {
65
114
  const source = stripCComments(text);
66
115
  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]);
67
- return { local, external: external.filter((name) => !name.startsWith('node:')) };
116
+ // `@/x`, `~/x` and `#x` are project-internal by convention: a path alias, a subpath import. Counting
117
+ // them as packages made a tree full of aliases look like it had one external dependency and almost no
118
+ // internal edges - the quietest way for a scan to be wrong.
119
+ const aliasShaped = (name) => name.startsWith('@/') || name.startsWith('~/') || name.startsWith('#');
120
+ // A call whose target is not a literal cannot be resolved from the source at all. Counting it is the
121
+ // honest move: the graph is missing those edges and the report says how many.
122
+ const dynamic = [...source.matchAll(/(?:^|[^.\w])import\s*\(\s*[^'"\s)]|(?:^|[^.\w])require\s*\(\s*[^'"\s)]/g)].length;
123
+ return { local: [...local, ...external.filter(aliasShaped)], external: external.filter((name) => !name.startsWith('node:') && !aliasShaped(name)), dynamic };
68
124
  },
69
125
  };
70
126
  const C_LIKE = {
@@ -418,6 +474,23 @@ function resolveSpecifier(language, fromId, specifier, index) {
418
474
  }
419
475
  if (specifier.startsWith('/'))
420
476
  return undefined;
477
+ if (!specifier.startsWith('.')) {
478
+ // A bare specifier is resolved the way the project declares it: a path alias first, then `baseUrl`.
479
+ // That is also the only way a bare specifier can be project-internal, so the same helper decides
480
+ // whether one is an edge or a package.
481
+ const declared = resolveDeclared(language, specifier, index);
482
+ if (declared !== undefined)
483
+ return declared;
484
+ }
485
+ // A C/C++ include that is not beside the importing file is usually found through an include directory
486
+ // the build already knows about, so `compile_commands.json` is read rather than asked for.
487
+ if (language.name === 'c' && !specifier.startsWith('.')) {
488
+ for (const directory of index.includeDirs) {
489
+ const hit = tryFiles(normalise([directory, specifier]), language.extensions, index);
490
+ if (hit !== undefined)
491
+ return hit;
492
+ }
493
+ }
421
494
  const base = joinPath(fromId, specifier);
422
495
  const extension = extname(base);
423
496
  const stem = extension === '' ? base : base.slice(0, -extension.length);
@@ -429,6 +502,149 @@ function resolveSpecifier(language, fromId, specifier, index) {
429
502
  return candidates.find((candidate) => index.files.has(candidate));
430
503
  }
431
504
  // ------------------------------------------------------------------------------------------------ scan --
505
+ /**
506
+ * A bare specifier that the project itself declares: a `paths` alias, or a file under `baseUrl`.
507
+ *
508
+ * `@/lib/a` is not a package, and counting it as one dropped the edge without saying so. Returns
509
+ * undefined when nothing is declared for it, which leaves the caller free to treat it as external.
510
+ */
511
+ function resolveDeclared(language, specifier, index) {
512
+ // A workspace package is the project too: `import { x } from '@acme/ui'` in a monorepo is an edge, not
513
+ // an external dependency. The name comes from the package's own manifest.
514
+ for (const [name, directory] of index.workspaces) {
515
+ if (specifier !== name && !specifier.startsWith(name + '/'))
516
+ continue;
517
+ const rest = specifier === name ? '' : specifier.slice(name.length + 1);
518
+ const hit = tryFiles(normalise([directory, rest]), language.extensions, index)
519
+ ?? tryFiles(normalise([directory]), language.extensions, index)
520
+ ?? tryFiles(normalise([directory, 'src']), language.extensions, index);
521
+ if (hit !== undefined)
522
+ return hit;
523
+ }
524
+ for (const alias of index.aliases) {
525
+ if (!specifier.startsWith(alias.prefix))
526
+ continue;
527
+ const rest = specifier.slice(alias.prefix.length);
528
+ for (const target of alias.targets) {
529
+ const hit = tryFiles(normalise([target + rest]), language.extensions, index);
530
+ if (hit !== undefined)
531
+ return hit;
532
+ }
533
+ }
534
+ if (index.baseUrl !== undefined) {
535
+ return tryFiles(normalise([index.baseUrl, specifier]), language.extensions, index);
536
+ }
537
+ return undefined;
538
+ }
539
+ /**
540
+ * Does this package-language specifier name something this tree owns?
541
+ *
542
+ * `com.acme.b.B` in a tree whose top level is `com/`, and `example.com/proj/pkg/b` in a module whose
543
+ * `go.mod` says `module example.com/proj`, are both internal: if they do not resolve, the reference is
544
+ * broken. `java.util.List` and `lodash` are not.
545
+ */
546
+ function isInternalSpecifier(specifier, roots) {
547
+ const head = specifier.replaceAll('\\', '/').split(/[/.]/).filter(Boolean)[0];
548
+ if (head === undefined)
549
+ return false;
550
+ return roots.some((root) => root === head || specifier.startsWith(root + '/') || specifier.startsWith(root + '.'));
551
+ }
552
+ /** Workspace packages declared by the root manifest, so a monorepo's own names resolve inside the tree. */
553
+ function readWorkspaces(root) {
554
+ const names = new Map();
555
+ let manifest;
556
+ try {
557
+ manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
558
+ }
559
+ catch {
560
+ return names;
561
+ }
562
+ if (typeof manifest !== 'object' || manifest === null)
563
+ return names;
564
+ const declared = manifest.workspaces;
565
+ const patterns = Array.isArray(declared)
566
+ ? declared
567
+ : typeof declared === 'object' && declared !== null && Array.isArray(declared.packages)
568
+ ? (declared.packages)
569
+ : [];
570
+ for (const pattern of patterns) {
571
+ if (typeof pattern !== 'string' || pattern.includes('*'))
572
+ continue;
573
+ const directory = pattern.replace(/^\.\//, '').replace(/\/$/, '');
574
+ try {
575
+ const entry = JSON.parse(readFileSync(resolve(root, directory, 'package.json'), 'utf8'));
576
+ if (typeof entry.name === 'string' && entry.name !== '')
577
+ names.set(entry.name, directory);
578
+ }
579
+ catch {
580
+ continue;
581
+ }
582
+ }
583
+ return names;
584
+ }
585
+ /** `-I`/`-isystem` directories from `compile_commands.json`, kept inside the scanned tree. */
586
+ function readIncludeDirs(root) {
587
+ let entries;
588
+ try {
589
+ entries = JSON.parse(readFileSync(resolve(root, 'compile_commands.json'), 'utf8'));
590
+ }
591
+ catch {
592
+ return [];
593
+ }
594
+ if (!Array.isArray(entries))
595
+ return [];
596
+ const dirs = new Set();
597
+ for (const entry of entries) {
598
+ if (typeof entry !== 'object' || entry === null)
599
+ continue;
600
+ const record = entry;
601
+ const declared = typeof record.directory === 'string' ? record.directory.replaceAll(String.fromCharCode(92), '/') : '.';
602
+ const base = declared.startsWith('/') || /^[A-Za-z]:/.test(declared) ? declared : resolve(root, declared).replaceAll(String.fromCharCode(92), '/');
603
+ const args = Array.isArray(record.arguments)
604
+ ? record.arguments.filter((argument) => typeof argument === 'string')
605
+ : typeof record.command === 'string'
606
+ ? record.command.split(/\s+/)
607
+ : [];
608
+ for (let index = 0; index < args.length; index += 1) {
609
+ const argument = args[index];
610
+ let value;
611
+ if (argument === '-I' || argument === '-isystem')
612
+ value = args[index + 1];
613
+ else if (argument.startsWith('-I'))
614
+ value = argument.slice(2);
615
+ else if (argument.startsWith('-isystem'))
616
+ value = argument.slice(8);
617
+ if (value === undefined || value === '')
618
+ continue;
619
+ const absolute = value.startsWith('/') || /^[A-Za-z]:/.test(value) ? value : base + '/' + value;
620
+ const local = relative(root, absolute).replaceAll(String.fromCharCode(92), '/');
621
+ if (local.startsWith('..'))
622
+ continue;
623
+ dirs.add(local === '' ? '.' : local);
624
+ }
625
+ }
626
+ return [...dirs].sort();
627
+ }
628
+ /** The package roots this tree owns: `module` from `go.mod`, and the top-level names on disk. */
629
+ function internalRootsOf(root, directories) {
630
+ const roots = new Set();
631
+ for (const directory of directories) {
632
+ const head = directory.split('/')[0];
633
+ if (head !== undefined && head !== '')
634
+ roots.add(head);
635
+ }
636
+ try {
637
+ for (const line of readFileSync(resolve(root, 'go.mod'), 'utf8').split(String.fromCharCode(10)).map((entry) => entry.trim())) {
638
+ const match = /^module\s+(\S+)/.exec(line);
639
+ if (match?.[1] !== undefined)
640
+ roots.add(match[1]);
641
+ }
642
+ }
643
+ catch {
644
+ // No go.mod: the top-level names are the whole set.
645
+ }
646
+ return [...roots].sort();
647
+ }
432
648
  export function scanDependencyGraph(root) {
433
649
  if (typeof root !== 'string' || root.trim() === '') {
434
650
  throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'repoPath must be a non-empty path to a directory or a single source file.');
@@ -444,7 +660,7 @@ export function scanDependencyGraph(root) {
444
660
  }
445
661
  if (stat.isFile()) {
446
662
  const id = relative(resolve('/'), absolute).slice(1).replaceAll('\\', '/');
447
- return { nodes: [id], edges: [], filesRead: 1, unresolved: [], packages: [], languages: [] };
663
+ return { nodes: [id], edges: [], filesRead: 1, unresolved: [], packages: [], dynamic: 0, languages: [] };
448
664
  }
449
665
  if (!stat.isDirectory()) {
450
666
  throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'repoPath must be a directory or a source file.');
@@ -494,24 +710,35 @@ export function scanDependencyGraph(root) {
494
710
  const byDotted = new Map();
495
711
  for (const directory of directories)
496
712
  byDotted.set(directory.replaceAll('/', '.'), directory);
497
- const index = { files: fileSet, directories, byDotted };
713
+ const index = {
714
+ files: fileSet,
715
+ directories,
716
+ byDotted,
717
+ ...readAliases(absolute),
718
+ workspaces: readWorkspaces(absolute),
719
+ includeDirs: readIncludeDirs(absolute),
720
+ internalRoots: internalRootsOf(absolute, directories),
721
+ };
498
722
  const edges = [];
499
723
  const seen = new Set();
500
724
  const unresolved = [];
501
725
  const packages = new Set();
502
726
  const nodeSet = new Set();
727
+ let dynamic = 0;
503
728
  for (const id of files) {
504
729
  const language = languageOf(id);
505
- const { local, external } = language.extract(contents.get(id) ?? '');
730
+ const { local, external, dynamic: dynamicHere } = language.extract(contents.get(id) ?? '');
731
+ dynamic += dynamicHere ?? 0;
506
732
  const nodeOf = (fileId) => (language.granularity === 'package' ? (dirOf(fileId) === '' ? '.' : dirOf(fileId)) : fileId);
507
733
  const from = nodeOf(id);
508
734
  nodeSet.add(from);
509
735
  for (const specifier of local) {
510
736
  const target = resolveSpecifier(language, id, specifier, index);
511
737
  if (target === undefined) {
512
- // A package language names modules by a path this tree may not contain. That is an external
513
- // dependency, not a broken reference, so it is counted rather than reported as unresolved.
514
- if (language.granularity === 'package') {
738
+ // A package language names modules by a path this tree may or may not contain. When the path
739
+ // starts at a root this tree owns - the `go.mod` module, a top-level package directory - it is a
740
+ // broken reference and has to be reported; anything else is an external dependency.
741
+ if (language.granularity === 'package' && !isInternalSpecifier(specifier, index.internalRoots)) {
515
742
  const name = specifier.split(/[/.]/).filter(Boolean)[0];
516
743
  if (name !== undefined)
517
744
  packages.add(name);
@@ -533,6 +760,19 @@ export function scanDependencyGraph(root) {
533
760
  edges.push({ from, to });
534
761
  }
535
762
  for (const specifier of external) {
763
+ const internal = language.granularity === 'file' ? resolveDeclared(language, specifier, index) : undefined;
764
+ if (internal !== undefined) {
765
+ const target = nodeOf(internal);
766
+ nodeSet.add(target);
767
+ if (target !== from) {
768
+ const key = from + '' + target;
769
+ if (!seen.has(key)) {
770
+ seen.add(key);
771
+ edges.push({ from, to: target });
772
+ }
773
+ }
774
+ continue;
775
+ }
536
776
  const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split(/[/.:]/)[0];
537
777
  if (name !== undefined && name !== '')
538
778
  packages.add(name);
@@ -543,5 +783,5 @@ export function scanDependencyGraph(root) {
543
783
  .sort((a, b) => b.files - a.files);
544
784
  edges.sort((a, b) => (a.from === b.from ? a.to.localeCompare(b.to) : a.from.localeCompare(b.to)));
545
785
  unresolved.sort();
546
- return { nodes: [...nodeSet].sort(), edges, filesRead: files.length, unresolved, packages: [...packages].sort(), languages };
786
+ return { nodes: [...nodeSet].sort(), edges, filesRead: files.length, unresolved, packages: [...packages].sort(), dynamic, languages };
547
787
  }
@@ -34,7 +34,7 @@ export const umlStructureTool = defineTool({
34
34
  },
35
35
  repoPath: {
36
36
  type: 'string',
37
- 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.',
37
+ 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#). Path aliases come from the project own `tsconfig.json`/`jsconfig.json` (`baseUrl` and `paths`), a monorepo package name from the root `package.json` workspaces, and C include directories from `compile_commands.json`. A project-internal specifier that resolves to nothing is listed as unresolved rather than counted as an external package, and an import whose target is computed at run time is counted rather than guessed. The graph is reviewed by the same checks; `diagram` and `diagrams` are then ignored.',
38
38
  },
39
39
  matrix: {
40
40
  type: 'json',
@@ -105,7 +105,7 @@ export const umlStructureTool = defineTool({
105
105
  return reviewStructure({
106
106
  diagram: '',
107
107
  graph,
108
- scan: { files: scanned.filesRead, unresolved: scanned.unresolved.length, packages: scanned.packages.length, languages: scanned.languages.map((entry) => entry.language) },
108
+ scan: { files: scanned.filesRead, unresolved: scanned.unresolved.length, packages: scanned.packages.length, dynamic: scanned.dynamic, languages: scanned.languages.map((entry) => entry.language) },
109
109
  ...(args.matrix === undefined ? {} : { matrix: args.matrix }),
110
110
  });
111
111
  }