@vizualmodel/vmblu-cli 0.1.0 → 0.2.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/bin/vmblu.js CHANGED
@@ -1,47 +1,65 @@
1
1
  #!/usr/bin/env node
2
- /* Minimal subcommand router: vmblu <command> [args] */
3
- const fs = require('fs');
4
- const path = require('path');
5
-
6
- const root = path.join(__dirname, '..');
7
- const commandsDir = path.join(root, 'commands');
8
-
9
- function printGlobalHelp() {
10
- const cmds = fs.readdirSync(commandsDir)
11
- .filter(n => fs.existsSync(path.join(commandsDir, n, 'index.js')));
12
- console.log(`vmblu <command> [options]
13
-
14
- Commands:
15
- ${cmds.map(c => `- ${c}`).join('\n ')}
16
-
17
- Run "vmblu <command> --help" for details.`);
18
- }
19
-
20
- async function run() {
21
- const [,, cmd, ...rest] = process.argv;
22
-
23
- if (!cmd || ['-h','--help','help'].includes(cmd)) {
24
- printGlobalHelp(); process.exit(0);
25
- }
26
- if (['-v','--version','version'].includes(cmd)) {
27
- console.log(require(path.join(root, 'package.json')).version); process.exit(0);
28
- }
29
-
30
- const entry = path.join(commandsDir, cmd, 'index.js');
31
- if (!fs.existsSync(entry)) {
32
- console.error(`Unknown command: ${cmd}\n`); printGlobalHelp(); process.exit(1);
33
- }
34
-
35
- const mod = require(entry);
36
- // Optional per-command help
37
- if (rest.includes('--help') || rest.includes('-h')) {
38
- console.log(`vmblu ${mod.command}\n\n${mod.describe}\n\nOptions:\n${(mod.builder || []).map(o => ` ${o.flag}\t${o.desc}`).join('\n')}`);
39
- process.exit(0);
40
- }
41
- await mod.handler(rest);
42
- }
43
-
44
- run().catch(err => {
45
- console.error(err?.stack || String(err));
46
- process.exit(1);
47
- });
2
+ /* Minimal subcommand router: vmblu <command> [args] */
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath, pathToFileURL } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const root = path.join(__dirname, '..');
10
+ const commandsDir = path.join(root, 'commands');
11
+
12
+ function printGlobalHelp() {
13
+ const cmds = fs.readdirSync(commandsDir)
14
+ .filter((name) => fs.existsSync(path.join(commandsDir, name, 'index.js')));
15
+ console.log(`vmblu <command> [options]
16
+
17
+ Commands:
18
+ ${cmds.map((c) => `- ${c}`).join('\n ')}
19
+
20
+ Run "vmblu <command> --help" for details.`);
21
+ }
22
+
23
+ async function run() {
24
+ const [, , cmd, ...rest] = process.argv;
25
+
26
+ if (!cmd || ['-h', '--help', 'help'].includes(cmd)) {
27
+ printGlobalHelp();
28
+ process.exit(0);
29
+ }
30
+
31
+ if (['-v', '--version', 'version'].includes(cmd)) {
32
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
33
+ console.log(pkg.version);
34
+ process.exit(0);
35
+ }
36
+
37
+ const entry = path.join(commandsDir, cmd, 'index.js');
38
+ if (!fs.existsSync(entry)) {
39
+ console.error(`Unknown command: ${cmd}\n`);
40
+ printGlobalHelp();
41
+ process.exit(1);
42
+ }
43
+
44
+ const mod = await import(pathToFileURL(entry));
45
+ const handler = mod.handler ?? mod.default;
46
+ if (typeof handler !== 'function') {
47
+ console.error(`Command "${cmd}" does not export a runnable handler.`);
48
+ process.exit(1);
49
+ }
50
+
51
+ if (rest.includes('--help') || rest.includes('-h')) {
52
+ const command = mod.command ?? cmd;
53
+ const describe = mod.describe ?? '';
54
+ const builder = Array.isArray(mod.builder) ? mod.builder : [];
55
+ console.log(`vmblu ${command}\n\n${describe}\n\nOptions:\n${builder.map((o) => ` ${o.flag}\t${o.desc}`).join('\n')}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ await handler(rest);
60
+ }
61
+
62
+ run().catch((err) => {
63
+ console.error(err?.stack || String(err));
64
+ process.exit(1);
65
+ });
@@ -1,20 +1,23 @@
1
- // vmblu init [targetDir] --name <project> --schema <ver> --force --dry-run
2
- const path = require('path');
3
- const { initProject } = require('./init-project');
1
+ // vmblu init [targetDir] --name <project> --schema <ver> --force --dry-run
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { initProject } from './init-project.js';
4
5
 
5
- exports.command = 'init';
6
- exports.describe = 'Scaffold an empty vmblu project';
7
- exports.builder = [
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export const command = 'init <folder name>';
9
+ export const describe = 'Scaffold an empty vmblu project';
10
+ export const builder = [
8
11
  { flag: '--name <project>', desc: 'Project name (default: folder name)' },
9
12
  { flag: '--schema <ver>', desc: 'Schema version (default: 0.8.2)' },
10
13
  { flag: '--force', desc: 'Overwrite existing files' },
11
14
  { flag: '--dry-run', desc: 'Show actions without writing' }
12
15
  ];
13
16
 
14
- exports.handler = async (argv) => {
17
+ export const handler = async (argv) => {
15
18
  // tiny arg parse (no deps)
16
19
  const args = { _: [] };
17
- for (let i=0;i<argv.length;i++) {
20
+ for (let i = 0; i < argv.length; i++) {
18
21
  const a = argv[i];
19
22
  if (a === '--force') args.force = true;
20
23
  else if (a === '--dry-run') args.dryRun = true;
@@ -31,8 +34,8 @@ exports.handler = async (argv) => {
31
34
  targetDir,
32
35
  projectName,
33
36
  schemaVersion,
34
- force: !!args.force,
35
- dryRun: !!args.dryRun,
37
+ force: Boolean(args.force),
38
+ dryRun: Boolean(args.dryRun),
36
39
  templatesDir: path.join(__dirname, '..', '..', 'templates'),
37
40
  ui: {
38
41
  info: (m) => console.log(m),
@@ -41,5 +44,6 @@ exports.handler = async (argv) => {
41
44
  }
42
45
  });
43
46
 
44
- console.log(`✔ vmblu project scaffolded in ${targetDir}`);
47
+ console.log(`vmblu project scaffolded in ${targetDir}`);
45
48
  };
49
+
@@ -1,10 +1,10 @@
1
1
  // core/initProject.js
2
2
  // Node 18+ (fs/promises, crypto). No external deps.
3
- const fs = require('fs/promises');
4
- const fssync = require('fs');
5
- const path = require('path');
6
- //const crypto = require('crypto');
7
- const pckg = require('./make-package-json');
3
+ import * as fs from 'fs/promises';
4
+ import * as fssync from 'fs';
5
+ import path from 'path';
6
+ //import crypto from 'crypto';
7
+ import { makePackageJson } from './make-package-json.js';
8
8
 
9
9
  function rel(from, to) {
10
10
  return path.posix.join(...path.relative(from, to).split(path.sep));
@@ -251,7 +251,7 @@ async function initProject(opts) {
251
251
  }
252
252
 
253
253
  // 5) Make the package file
254
- pckg.makePackageJson( {absTarget, projectName, force, dryRun, addCliDep: true, cliVersion: "^0.1.0"}, ui);
254
+ makePackageJson({ absTarget, projectName, force, dryRun, addCliDep: true, cliVersion: "^0.1.0" }, ui);
255
255
 
256
256
  // 6) Final tree hint
257
257
  ui.info(`\nScaffold complete${dryRun ? ' (dry run)' : ''}:\n` +
@@ -285,4 +285,4 @@ async function initProject(opts) {
285
285
  };
286
286
  }
287
287
 
288
- module.exports = { initProject };
288
+ export { initProject };
@@ -1,7 +1,7 @@
1
- const fs = require('fs/promises');
2
- const path = require('path');
3
-
4
- async function readJsonIfExists(file) {
1
+ import * as fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ async function readJsonIfExists(file) {
5
5
  try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return null; }
6
6
  }
7
7
 
@@ -9,10 +9,10 @@ function sortKeys(obj) {
9
9
  return Object.fromEntries(Object.entries(obj).sort(([a],[b]) => a.localeCompare(b)));
10
10
  }
11
11
 
12
- async function makePackageJson({
13
- absTarget, projectName, force, dryRun,
14
- addCliDep = true, cliVersion = "^0.1.0"
15
- }, ui) {
12
+ export async function makePackageJson({
13
+ absTarget, projectName, force, dryRun,
14
+ addCliDep = true, cliVersion = "^0.1.0"
15
+ }, ui) {
16
16
  const pkgPath = path.join(absTarget, 'package.json');
17
17
  const existing = await readJsonIfExists(pkgPath);
18
18
 
@@ -54,6 +54,5 @@ async function makePackageJson({
54
54
  await fs.writeFile(pkgPath, JSON.stringify(ordered, null, 2) + '\n', 'utf8');
55
55
  }
56
56
 
57
- return pkgPath;
58
- }
59
- module.exports = { makePackageJson };
57
+ return pkgPath;
58
+ }
@@ -0,0 +1,351 @@
1
+ // extractHandlersFromFile.js
2
+
3
+ import ts from 'typescript';
4
+
5
+ export let currentNode = null;
6
+ export let topLevelClass = null
7
+ let nodeMap = null
8
+ let filePath = null
9
+
10
+ export function findHandlers(sourceFile, _filePath, _nodeMap) {
11
+
12
+ // Reset any node context carried over from previous files.
13
+ currentNode = null;
14
+
15
+ // The fallback name is the top-level class
16
+ topLevelClass = sourceFile.getClasses()[0]?.getName?.() || null;
17
+ nodeMap = _nodeMap
18
+ filePath = _filePath
19
+
20
+ // Check all the functions in the sourcefile - typically generator functions
21
+ sourceFile.getFunctions().forEach(fn => {
22
+
23
+ // Capture node annotations on generator-style functions and harvest handlers returned from them.
24
+ const jsdoc = getFullJsDoc(fn);
25
+ updateNodeFromJsdoc(jsdoc);
26
+
27
+ const name = fn.getName();
28
+
29
+ if (isHandler(name)) {
30
+
31
+ const line = fn.getNameNode()?.getStartLineNumber() ?? fn.getStartLineNumber();
32
+ const docTags = getParamDocs(fn);
33
+ const params = fn.getParameters().flatMap(p => describeParam(p, docTags));
34
+
35
+ collect(name, params, line, jsdoc);
36
+ }
37
+
38
+ collectHandlersFromFunctionReturns(fn);
39
+ });
40
+
41
+ // Check the variable declarations in the sourcefile
42
+ sourceFile.getVariableDeclarations().forEach(decl => {
43
+
44
+ // check the name
45
+ const name = decl.getName();
46
+ const init = decl.getInitializer();
47
+ const line = decl.getStartLineNumber();
48
+ const jsdoc = getFullJsDoc(decl);
49
+ updateNodeFromJsdoc(jsdoc);
50
+
51
+ // check if the name is a handler and initialised with a function
52
+ if (isHandler(name) && init && init.getKindName().includes('Function')) {
53
+
54
+ const docTags = getParamDocs(decl);
55
+ const params = init.getParameters().flatMap(p => describeParam(p, docTags));
56
+
57
+ collect(name, params, line, jsdoc);
58
+ }
59
+
60
+ if (init && init.getKind() === ts.SyntaxKind.ObjectLiteralExpression) {
61
+ collectObjectLiteralHandlers(init);
62
+ }
63
+ });
64
+
65
+ // check all the classes in the file
66
+ sourceFile.getClasses().forEach(cls => {
67
+
68
+ // get the name of the node
69
+ const nodeName = cls.getName?.() || topLevelClass;
70
+
71
+ // check all the methods
72
+ cls.getMethods().forEach(method => {
73
+
74
+ // check the name
75
+ const name = method.getName();
76
+ if (!isHandler(name)) return;
77
+
78
+ // extract
79
+ const line = method.getNameNode()?.getStartLineNumber() ?? method.getStartLineNumber();
80
+ const jsdoc = getFullJsDoc(method);
81
+ const docTags = getParamDocs(method);
82
+ const params = method.getParameters().flatMap(p => describeParam(p, docTags));
83
+
84
+ // and collect
85
+ collect(name, params, line, jsdoc, nodeName);
86
+ });
87
+ });
88
+
89
+ // check all the statements
90
+ sourceFile.getStatements().forEach(stmt => {
91
+
92
+ // only binary expressions
93
+ if (!stmt.isKind(ts.SyntaxKind.ExpressionStatement)) return;
94
+ const expr = stmt.getExpression();
95
+ if (!expr.isKind(ts.SyntaxKind.BinaryExpression)) return;
96
+
97
+ // get the two parts of the statement
98
+ const left = expr.getLeft().getText();
99
+ const right = expr.getRight();
100
+
101
+ // check for protype
102
+ if (left.includes('.prototype.') && right.isKind(ts.SyntaxKind.FunctionExpression)) {
103
+
104
+ // get the name and check
105
+ const parts = left.split('.');
106
+ const name = parts[parts.length - 1];
107
+ if (!isHandler(name)) return;
108
+
109
+ // extract
110
+ const line = expr.getStartLineNumber();
111
+ const params = right.getParameters().flatMap(p => describeParam(p));
112
+ const jsdoc = getFullJsDoc(expr);
113
+
114
+ // and save in nodemap
115
+ collect(name, params, line, jsdoc);
116
+ }
117
+
118
+ if (left.endsWith('.prototype') && right.isKind(ts.SyntaxKind.ObjectLiteralExpression)) {
119
+ collectObjectLiteralHandlers(right);
120
+ }
121
+ });
122
+ }
123
+
124
+
125
+ function collectHandlersFromFunctionReturns(fn) {
126
+
127
+ // Look for factory-style returns that expose handlers via object literals.
128
+ fn.getDescendantsOfKind(ts.SyntaxKind.ReturnStatement).forEach(ret => {
129
+ const expr = ret.getExpression();
130
+ if (!expr || expr.getKind() !== ts.SyntaxKind.ObjectLiteralExpression) return;
131
+
132
+ collectObjectLiteralHandlers(expr);
133
+ });
134
+ }
135
+
136
+ function collectObjectLiteralHandlers(objectLiteral) {
137
+
138
+ // Reuse the same extraction logic for any handler stored on an object literal shape.
139
+ objectLiteral.getProperties().forEach(prop => {
140
+
141
+ const propName = prop.getName?.();
142
+ if (!isHandler(propName)) return;
143
+
144
+ let params = [];
145
+ if (prop.getKind() === ts.SyntaxKind.MethodDeclaration) {
146
+ const docTags = getParamDocs(prop);
147
+ params = prop.getParameters().flatMap(p => describeParam(p, docTags));
148
+ } else if (prop.getKind() === ts.SyntaxKind.PropertyAssignment) {
149
+ const fn = prop.getInitializerIfKind(ts.SyntaxKind.FunctionExpression) || prop.getInitializerIfKind(ts.SyntaxKind.ArrowFunction);
150
+ if (fn) {
151
+ const docTags = getParamDocs(fn);
152
+ params = fn.getParameters().flatMap(p => describeParam(p, docTags));
153
+ }
154
+ }
155
+
156
+ const jsdoc = getFullJsDoc(prop);
157
+ const line = prop.getStartLineNumber();
158
+
159
+ collect(propName, params, line, jsdoc);
160
+ });
161
+ }
162
+
163
+ function updateNodeFromJsdoc(jsdoc = {}) {
164
+
165
+ const nodeTag = jsdoc.tags?.find(t => t.tagName === 'node')?.comment;
166
+ if (nodeTag) currentNode = nodeTag.trim();
167
+ }
168
+
169
+ function collect(rawName, params, line, jsdoc = {}) {
170
+
171
+ //if (!isHandler(rawName)) return;
172
+ const cleanHandler = rawName.replace(/^['"]|['"]$/g, '');
173
+
174
+ let pin = null;
175
+ let node = null;
176
+
177
+ const pinTag = jsdoc.tags?.find(t => t.tagName === 'pin')?.comment;
178
+ const nodeTag = jsdoc.tags?.find(t => t.tagName === 'node')?.comment;
179
+ const mcpTag = jsdoc.tags?.find(t => t.tagName === 'mcp')?.comment ?? null;
180
+
181
+ // if there is a node tag, change the name of the current node
182
+ if (nodeTag) currentNode = nodeTag.trim();
183
+
184
+ // check the pin tag to get a pin name and node name
185
+ if (pinTag) {
186
+
187
+ if (pinTag.includes('@')) {
188
+ const [p, n] = pinTag.split('@').map(s => s.trim());
189
+ pin = p;
190
+ node = n;
191
+ }
192
+ else pin = pinTag.trim();
193
+
194
+ // Use the current context when the pin tag does not specify a node.
195
+ if (!node) node = currentNode || topLevelClass || null;
196
+ }
197
+
198
+ // check the pin tag to get a pin name and node name
199
+ // if (pinTag && pinTag.includes('@')) {
200
+ // const [p, n] = pinTag.split('@').map(s => s.trim());
201
+ // pin = p;
202
+ // node = n;
203
+ // }
204
+ else {
205
+
206
+ // no explicit tag - try these...
207
+ node = currentNode || topLevelClass || null;
208
+
209
+ // deduct the pin name from the handler name
210
+ if (cleanHandler.startsWith('on')) {
211
+ pin = cleanHandler.slice(2).replace(/([A-Z])/g, ' $1').trim().toLowerCase();
212
+ } else if (cleanHandler.startsWith('->')) {
213
+ pin = cleanHandler.slice(2).trim();
214
+ }
215
+ }
216
+
217
+ // if there is no node we just don't save the data
218
+ if (!node) return
219
+
220
+ // check if we have an entry for the node
221
+ if (!nodeMap.has(node)) nodeMap.set(node, { handles: [], transmits: [] });
222
+
223
+ // The handler data to save
224
+ const handlerData = {
225
+ pin,
226
+ handler: cleanHandler,
227
+ file: filePath,
228
+ line,
229
+ summary: jsdoc.summary || '',
230
+ returns: jsdoc.returns || '',
231
+ examples: jsdoc.examples || [],
232
+ params
233
+ };
234
+
235
+ // extract the data from an mcp tag if present
236
+ if (mcpTag !== null) {
237
+ handlerData.mcp = true;
238
+ if (mcpTag.includes('name:') || mcpTag.includes('description:')) {
239
+ const nameMatch = /name:\s*\"?([^\"]+)\"?/.exec(mcpTag);
240
+ const descMatch = /description:\s*\"?([^\"]+)\"?/.exec(mcpTag);
241
+ if (nameMatch) handlerData.mcpName = nameMatch[1];
242
+ if (descMatch) handlerData.mcpDescription = descMatch[1];
243
+ }
244
+ }
245
+
246
+ // and put it in the nodemap
247
+ nodeMap.get(node).handles.push(handlerData);
248
+ };
249
+
250
+ // determines if a name is the name for a handler
251
+ function isHandler(name) {
252
+ // must be a string
253
+ if (typeof name !== 'string') return false;
254
+
255
+ // get rid of " and '
256
+ const clean = name.replace(/^['"]|['"]$/g, '');
257
+
258
+ // check that it starts with the right symbols...
259
+ return clean.startsWith('on') || clean.startsWith('->');
260
+ }
261
+
262
+ // Get the parameter description from the function or method
263
+ function getParamDocs(fnOrMethod) {
264
+
265
+ // extract
266
+ const docs = fnOrMethod.getJsDocs?.() ?? [];
267
+ const tags = docs.flatMap(d => d.getTags());
268
+ const paramDocs = {};
269
+
270
+ // check the tags
271
+ for (const tag of tags) {
272
+ if (tag.getTagName() === 'param') {
273
+ const name = tag.getNameNode()?.getText?.() || tag.getName();
274
+ const desc = tag.getComment() ?? '';
275
+ const type = tag.getTypeNode?.()?.getText?.() || tag.getTypeExpression()?.getTypeNode()?.getText();
276
+ paramDocs[name] = { description: desc, type };
277
+ }
278
+ }
279
+ return paramDocs;
280
+ }
281
+
282
+ // Get the jsdoc
283
+ function getFullJsDoc(node) {
284
+
285
+ const docs = node.getJsDocs?.() ?? [];
286
+ const summary = docs.map(d => d.getComment()).filter(Boolean).join('\n');
287
+ const tags = docs.flatMap(d => d.getTags()).map(t => ({
288
+ tagName: t.getTagName(),
289
+ comment: t.getComment() || ''
290
+ }));
291
+
292
+ const returns = tags.find(t => t.tagName === 'returns')?.comment || '';
293
+ const examples = tags.filter(t => t.tagName === 'example').map(t => t.comment);
294
+
295
+ return { summary, returns, examples, tags };
296
+ }
297
+
298
+ // make a parameter description
299
+ function describeParam(p, docTags = {}) {
300
+
301
+ const nameNode = p.getNameNode();
302
+
303
+ // const func = p.getParent();
304
+ // const funcName = func.getName?.() || '<anonymous>';
305
+ // console.log(funcName)
306
+
307
+ if (nameNode.getKindName() === 'ObjectBindingPattern') {
308
+
309
+ const objType = p.getType();
310
+ const properties = objType.getProperties();
311
+ const isTSFallback = objType.getText() === 'any' || objType.getText() === 'string' || properties.length === 0;
312
+
313
+ return nameNode.getElements().map(el => {
314
+
315
+ const subName = el.getName();
316
+ const doc = docTags[subName] ?? {};
317
+ let tsType = null;
318
+
319
+ if (!isTSFallback) {
320
+ const symbol = objType.getProperty(subName);
321
+ if (symbol) {
322
+ const resolvedType = symbol.getTypeAtLocation(el);
323
+ const text = resolvedType.getText();
324
+ if (text && text !== 'any') {
325
+ tsType = text;
326
+ }
327
+ }
328
+ }
329
+
330
+ const type = tsType || doc.type || 'string';
331
+ const description = doc.description || '';
332
+ return { name: subName, type, description };
333
+ });
334
+ }
335
+
336
+ const name = p.getName();
337
+ const doc = docTags[name] ?? {};
338
+ const tsType = p.getType().getText();
339
+
340
+ // const isTSFallback = tsType === 'any' || tsType === 'string';
341
+ // if (isTSFallback && !doc.type) {
342
+ // console.warn(`⚠️ No type info for param "${name}" in function "${funcName}"`);
343
+ // }
344
+
345
+ return {
346
+ name,
347
+ type: doc.type || tsType || 'string',
348
+ description: doc.description || '',
349
+ };
350
+ }
351
+
@@ -0,0 +1,54 @@
1
+ import { SyntaxKind } from 'ts-morph';
2
+ import {currentNode, topLevelClass} from './find-handlers.js'
3
+
4
+ /**
5
+ * Finds tx.send or this.tx.send calls and maps them to their node context.
6
+ *
7
+ * @param {import('ts-morph').SourceFile} sourceFile - The source file being analyzed
8
+ * @param {string} filePath - The (relative) path of the source file
9
+ * @param {Map} nodeMap - Map from node name to metadata
10
+ * @param {string|null} currentNode - Explicitly set node name (takes priority)
11
+ */
12
+ export function findTransmissions(sourceFile, filePath, nodeMap) {
13
+
14
+ // Search all call expressions
15
+ sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).forEach(node => {
16
+ const expr = node.getExpression();
17
+
18
+ // check
19
+ if (expr.getKind() !== SyntaxKind.PropertyAccessExpression) return
20
+
21
+ // Match tx.send or this.tx.send - regular expression could be : expr.getText().match(/\w+\.tx\.send/)
22
+ const text = expr.getText()
23
+
24
+ // check
25
+ if (! (text === 'tx.send' || text === 'this.tx.send' || text.endsWith('.tx.send'))) return;
26
+
27
+ const args = node.getArguments();
28
+ if (args.length === 0 || !args[0].isKind(SyntaxKind.StringLiteral)) return;
29
+
30
+ const pin = args[0].getLiteralText();
31
+
32
+ // Try to infer the class context of the tx.send call
33
+ const method = node.getFirstAncestorByKind(SyntaxKind.MethodDeclaration);
34
+ const classDecl = method?.getFirstAncestorByKind(SyntaxKind.ClassDeclaration) ?? node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
35
+ const className = classDecl?.getName?.();
36
+
37
+ // Priority order: currentNode > className > topLevelClass > 'global'
38
+ const nodeName = currentNode || className || topLevelClass || null;
39
+
40
+ // check
41
+ if (!nodeName) return
42
+
43
+ // check if there is an entry for the node or create it
44
+ nodeMap.has(nodeName) || nodeMap.set(nodeName, { handles: [], transmits: [] });
45
+
46
+ // add the entry to the transmits array
47
+ nodeMap.get(nodeName).transmits.push({
48
+ pin,
49
+ file: filePath,
50
+ line: node.getStartLineNumber()
51
+ });
52
+ });
53
+ }
54
+
@@ -0,0 +1,16 @@
1
+ import { profile } from './profile.js';
2
+
3
+ export const command = 'profile <model-file>';
4
+ export const describe = 'Find message handlers and message transmissions.';
5
+
6
+ export const builder = [
7
+ { flag: '--out <file>', desc: 'specifies the output file' },
8
+ { flag: '--full', desc: 'check all source files in the model' },
9
+ { flag: '--changed <files...>', desc: 'only check changed files' },
10
+ { flag: '--deleted <files...>', desc: 'remove data from deleted files' },
11
+ { flag: '--delta-file <path>', desc: 'write the delta to a file' },
12
+ { flag: '--reason <text>', desc: 'information' },
13
+ ];
14
+
15
+ export const handler = profile;
16
+
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Native
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import ts from 'typescript';
8
+
9
+ // ts-morph
10
+ import { Project } from 'ts-morph';
11
+
12
+ // vmblu
13
+ import { ModelBlueprint, ModelCompiler } from '../../../core/model/index.js';
14
+ import { ARL } from '../../../core/arl/arl-node.js'
15
+
16
+ // profile tool
17
+ import {findHandlers} from './find-handlers.js'
18
+ import {findTransmissions} from './find-transmissions.js'
19
+
20
+ const SRC_DOC_VERSION = '0.2';
21
+
22
+ // The main function for the profile tool
23
+ export async function profile(argv = process.argv.slice(2)) {
24
+
25
+ const cli = parseCliArgs(argv);
26
+
27
+ if (!cli.modelFile) {
28
+ //console.error('Usage: node profile <model-file> [--out <file>] [--full] [--changed <files...>] [--deleted <files...>] [--delta-file <path>] [--reason <text>]');
29
+ console.error('Usage: vmblu profile <model-file> [--out <file>] [--full] [--changed <files...>] [--deleted <files...>] [--delta-file <path>] [--reason <text>]');
30
+ process.exit(1);
31
+ }
32
+
33
+ const absoluteModelPath = path.resolve(cli.modelFile);
34
+ const modelPath = absoluteModelPath.replace(/\\/g, '/');
35
+
36
+ if (!fs.existsSync(absoluteModelPath) || !fs.statSync(absoluteModelPath).isFile()) {
37
+ console.error(cli.modelFile, 'is not a file');
38
+ process.exit(1);
39
+ }
40
+
41
+ const outPath = cli.outFile
42
+ ? path.resolve(cli.outFile)
43
+ : (() => {
44
+ const { dir, name } = path.parse(absoluteModelPath);
45
+ return path.join(dir, `${name}-doc.json`);
46
+ })();
47
+
48
+ if (cli.deltaFile) cli.deltaFile = path.resolve(cli.deltaFile);
49
+ if (cli.reason) console.log('[profile] reason:', cli.reason);
50
+
51
+ if (!cli.full && (cli.changed.length || cli.deleted.length || cli.deltaFile)) {
52
+ console.log('[profile] incremental updates not yet supported; performing full rescan.');
53
+ }
54
+
55
+ // Make an Application Resource Locator // Make an Application Resource Locator
56
+ const arl = new ARL(modelPath);
57
+
58
+ // Create model object
59
+ const model = new ModelBlueprint(arl);
60
+
61
+ // create a model compile object - we do not need a uid generator
62
+ const compiler = new ModelCompiler(null);
63
+
64
+ // get all the factories that are refernced in the model and submodels
65
+ await compiler.getFactoriesAndModels(model);
66
+
67
+ // extract the factories
68
+ const factories = compiler.factories.map.values();
69
+
70
+ // setup the ts-morph project with the factory files
71
+ const project = setupProject(factories)
72
+
73
+ // Extract the source files
74
+ const sourceFiles = project.getSourceFiles()
75
+
76
+ // get all the handlers and transmissions of all the source files into the rxtx array
77
+ const rxtx = []
78
+ const generatedAt = new Date().toISOString()
79
+ for (const sourceFile of sourceFiles) {
80
+
81
+ // A file reference is always relative to the model file
82
+ const filePath = path.relative(path.dirname(modelPath), sourceFile.getFilePath()).replace(/\\/g, '/');
83
+
84
+ // the node map to collect the data for the file
85
+ const nodeMap = new Map();
86
+
87
+ // find the handlers in the file
88
+ findHandlers(sourceFile, filePath, nodeMap)
89
+
90
+ // find the transmissions in the file
91
+ findTransmissions(sourceFile, filePath, nodeMap)
92
+
93
+ // map the nodemap to an array
94
+ const nodeArray = Array.from(nodeMap.entries()).map(([node, { handles, transmits }]) => ({node,handles,transmits}))
95
+
96
+ // add these to the overall rxtx array
97
+ rxtx.push(...nodeArray)
98
+ }
99
+
100
+ // Assemble the output file path
101
+ // (outPath was resolved earlier based on CLI arguments)
102
+
103
+ // and write the output to that file
104
+ const output = {
105
+ version: SRC_DOC_VERSION,
106
+ generatedAt,
107
+ entries: rxtx
108
+ };
109
+
110
+ // Persist the structured documentation with its header so downstream tools can validate against the schema.
111
+ fs.writeFileSync(outPath, JSON.stringify(output, null, 2));
112
+ console.log(`Documentation written to ${outPath}`);
113
+ }
114
+
115
+ function parseCliArgs(argv) {
116
+
117
+ const result = {
118
+ modelFile: null,
119
+ outFile: null,
120
+ full: false,
121
+ reason: null,
122
+ changed: [],
123
+ deleted: [],
124
+ deltaFile: null,
125
+ };
126
+
127
+ let i = 0;
128
+ while (i < argv.length) {
129
+ const token = argv[i];
130
+
131
+ if (token === '--out') {
132
+ const next = argv[i + 1];
133
+ if (next && !next.startsWith('--')) {
134
+ result.outFile = next;
135
+ i += 2;
136
+ } else {
137
+ console.warn('Warning: --out requires a path argument; ignoring.');
138
+ i += 1;
139
+ }
140
+ continue;
141
+ }
142
+
143
+ if (token === '--full') {
144
+ result.full = true;
145
+ i += 1;
146
+ continue;
147
+ }
148
+
149
+ if (token === '--reason') {
150
+ const next = argv[i + 1];
151
+ if (next && !next.startsWith('--')) {
152
+ result.reason = next;
153
+ i += 2;
154
+ } else {
155
+ result.reason = '';
156
+ i += 1;
157
+ }
158
+ continue;
159
+ }
160
+
161
+ if (token === '--delta-file') {
162
+ const next = argv[i + 1];
163
+ if (next && !next.startsWith('--')) {
164
+ result.deltaFile = next;
165
+ i += 2;
166
+ } else {
167
+ console.warn('Warning: --delta-file requires a path argument; ignoring.');
168
+ i += 1;
169
+ }
170
+ continue;
171
+ }
172
+
173
+ if (token === '--changed') {
174
+ const values = [];
175
+ i += 1;
176
+ while (i < argv.length && !argv[i].startsWith('--')) {
177
+ values.push(argv[i]);
178
+ i += 1;
179
+ }
180
+ if (values.length === 0) {
181
+ console.warn('Warning: --changed provided without any paths.');
182
+ } else {
183
+ result.changed.push(...values);
184
+ }
185
+ continue;
186
+ }
187
+
188
+ if (token === '--deleted') {
189
+ const values = [];
190
+ i += 1;
191
+ while (i < argv.length && !argv[i].startsWith('--')) {
192
+ values.push(argv[i]);
193
+ i += 1;
194
+ }
195
+ if (values.length === 0) {
196
+ console.warn('Warning: --deleted provided without any paths.');
197
+ } else {
198
+ result.deleted.push(...values);
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (typeof token === 'string' && token.startsWith('--')) {
204
+ console.warn('Warning: unknown option "' + token + '" ignored.');
205
+ i += 1;
206
+ continue;
207
+ }
208
+
209
+ if (!result.modelFile) {
210
+ result.modelFile = token;
211
+ } else {
212
+ console.warn('Warning: extra positional argument "' + token + '" ignored.');
213
+ }
214
+
215
+ i += 1;
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+
222
+
223
+ // Gets all the source files that are part of this project
224
+ function setupProject(factories) {
225
+
226
+ // Initialize ts-morph without tsconfig
227
+ const project = new Project({
228
+ compilerOptions: {
229
+ allowJs: true,
230
+ checkJs: true,
231
+ module: ts.ModuleKind.ESNext,
232
+ target: ts.ScriptTarget.ESNext,
233
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
234
+ esModuleInterop: true,
235
+ noEmit: true,
236
+ },
237
+ skipAddingFilesFromTsConfig: true,
238
+ });
239
+
240
+ // Add factory entry files
241
+ for (const factory of factories) {
242
+
243
+ // get the file path
244
+ const filePath = factory.arl.url;
245
+
246
+ // user feedback
247
+ console.log('Adding factory entry:', filePath);
248
+
249
+ // add to the project
250
+ try {
251
+ project.addSourceFileAtPath(factory.arl.url);
252
+ } catch (err) {
253
+ console.warn(`Could not load ${filePath}: ${err.message}`);
254
+ }
255
+ }
256
+
257
+ // Resolve all imports recursively
258
+ project.resolveSourceFileDependencies();
259
+
260
+ // done
261
+ return project
262
+ }
263
+
264
+
265
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
266
+
267
+ if (isDirectRun) {
268
+ profile().catch(err => {
269
+ console.error('Failed to generate source documentation:', err);
270
+ process.exit(1);
271
+ });
272
+ }
@@ -0,0 +1,22 @@
1
+ import resolve from '@rollup/plugin-node-resolve';
2
+ import commonjs from '@rollup/plugin-commonjs';
3
+ import json from '@rollup/plugin-json';
4
+
5
+ export default {
6
+ input: './profile.js',
7
+ output: {
8
+ file: './profile.cjs', // ⬅️ Use .cjs extension and CommonJS format
9
+ format: 'cjs',
10
+ sourcemap: true
11
+ },
12
+ external: [
13
+ 'ts-morph',
14
+ 'typescript', // Exclude heavy dependencies from bundle
15
+ ],
16
+ plugins: [
17
+ commonjs(),
18
+ resolve({ preferBuiltins: true }),
19
+ json()
20
+ ]
21
+ };
22
+
package/package.json CHANGED
@@ -1,8 +1,18 @@
1
1
  {
2
2
  "name": "@vizualmodel/vmblu-cli",
3
- "version": "0.1.0",
4
- "type": "commonjs",
5
- "bin": { "vmblu": "bin/vmblu.js" },
6
- "files": ["bin", "commands", "templates", "README.md", "LICENSE"],
7
- "engines": { "node": ">=18" }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "vmblu": "bin/vmblu.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "commands",
11
+ "templates",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ }
8
18
  }