@memoarchitect/tools 0.11.2 → 0.12.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 (51) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/packages/tools/lib/browser/index.d.ts +1 -0
  4. package/packages/tools/lib/browser/index.d.ts.map +1 -1
  5. package/packages/tools/lib/browser/index.js +6 -0
  6. package/packages/tools/lib/browser/index.js.map +1 -1
  7. package/packages/tools/lib/model/builder.d.ts.map +1 -1
  8. package/packages/tools/lib/model/builder.js +95 -10
  9. package/packages/tools/lib/model/builder.js.map +1 -1
  10. package/packages/tools/lib/model/kind-registry.d.ts +12 -0
  11. package/packages/tools/lib/model/kind-registry.d.ts.map +1 -1
  12. package/packages/tools/lib/model/kind-registry.js +5 -0
  13. package/packages/tools/lib/model/kind-registry.js.map +1 -1
  14. package/packages/tools/lib/model/layer-resolver.d.ts.map +1 -1
  15. package/packages/tools/lib/model/layer-resolver.js +12 -4
  16. package/packages/tools/lib/model/layer-resolver.js.map +1 -1
  17. package/packages/tools/lib/model/ontology-loader.d.ts +27 -0
  18. package/packages/tools/lib/model/ontology-loader.d.ts.map +1 -1
  19. package/packages/tools/lib/model/ontology-loader.js +5 -3
  20. package/packages/tools/lib/model/ontology-loader.js.map +1 -1
  21. package/packages/tools/lib/model/relationship-legality.d.ts +2 -0
  22. package/packages/tools/lib/model/relationship-legality.d.ts.map +1 -1
  23. package/packages/tools/lib/model/relationship-legality.js +13 -3
  24. package/packages/tools/lib/model/relationship-legality.js.map +1 -1
  25. package/packages/tools/lib/model/relationship-registry.d.ts.map +1 -1
  26. package/packages/tools/lib/model/relationship-registry.js +15 -2
  27. package/packages/tools/lib/model/relationship-registry.js.map +1 -1
  28. package/packages/tools/lib/model/semantic.d.ts +0 -8
  29. package/packages/tools/lib/model/semantic.d.ts.map +1 -1
  30. package/packages/tools/lib/model/semantic.js.map +1 -1
  31. package/packages/tools/lib/model/view-deriver.d.ts +0 -9
  32. package/packages/tools/lib/model/view-deriver.d.ts.map +1 -1
  33. package/packages/tools/lib/model/view-deriver.js +121 -5
  34. package/packages/tools/lib/model/view-deriver.js.map +1 -1
  35. package/packages/tools/lib/protocol/messages.d.ts +20 -0
  36. package/packages/tools/lib/protocol/messages.d.ts.map +1 -1
  37. package/packages/tools/lib/server/dev-server.d.ts +1 -1
  38. package/packages/tools/lib/server/dev-server.d.ts.map +1 -1
  39. package/packages/tools/lib/server/dev-server.js +166 -44
  40. package/packages/tools/lib/server/dev-server.js.map +1 -1
  41. package/packages/tools/lib/server/persistor.d.ts.map +1 -1
  42. package/packages/tools/lib/server/persistor.js +156 -6
  43. package/packages/tools/lib/server/persistor.js.map +1 -1
  44. package/packages/tools/lib/server/view-writer.d.ts +44 -0
  45. package/packages/tools/lib/server/view-writer.d.ts.map +1 -0
  46. package/packages/tools/lib/server/view-writer.js +294 -0
  47. package/packages/tools/lib/server/view-writer.js.map +1 -0
  48. package/packages/tools/lib/sysml-ir/generated/sysml-metaclass-names.d.ts +4 -0
  49. package/packages/tools/lib/sysml-ir/generated/sysml-metaclass-names.d.ts.map +1 -0
  50. package/packages/tools/lib/sysml-ir/generated/sysml-metaclass-names.js +191 -0
  51. package/packages/tools/lib/sysml-ir/generated/sysml-metaclass-names.js.map +1 -0
@@ -20,6 +20,7 @@ import { assertSingleDomainMutation, MixedMutationDomainError } from './mutation
20
20
  import { classifyConflict } from './conflict-policy.js';
21
21
  import { checkRulePolicy, insertRulePolicy, renderRulePolicy } from './rule-policy-writer.js';
22
22
  import { loadViewLayouts, saveViewLayout } from './view-layout-store.js';
23
+ import { updateViewExpose, writeViewDeclaration } from './view-writer.js';
23
24
  import { usageKeyword } from '../model/semantic.js';
24
25
  import { loadDhfDocs, saveDhfDoc, deleteDhfDoc, loadDhfSettings, saveDhfSettings, listRepoTemplates, readRepoTemplate, saveRepoTemplate, } from './dhf-doc-store.js';
25
26
  const MODEL_MUTATION_MESSAGES = new Set([
@@ -92,7 +93,30 @@ function streamFile(res, fullPath, status = 200) {
92
93
  res.writeHead(status, { 'Content-Type': mime, 'Content-Length': size });
93
94
  createReadStream(fullPath).pipe(res);
94
95
  }
95
- /** Resolve only project-owned screen captures under model/assets. */
96
+ /**
97
+ * The subtrees a project's screen captures may be served from.
98
+ *
99
+ * `model/assets` is where MEMO put them back when the whole model lived under
100
+ * `model/`. A project laid out with its source in `src/` has no `model/`
101
+ * directory at all and keeps its captures in `assets/` beside it — and those
102
+ * were unreachable, so its UI Screens workspace drew every region over a
103
+ * blank backdrop with no error to explain it. The request simply fell through
104
+ * to the SPA and the `<img>` got an HTML document back.
105
+ *
106
+ * Naming roots at all is the allow-list, and that part is not negotiable:
107
+ * this server hands project files to a browser, so it serves these two
108
+ * subtrees and nothing else — not `.env`, not `.git/`, not the source.
109
+ */
110
+ const CAPTURE_ROOTS = ['model/assets', 'assets'];
111
+ /** True when `candidate` is a real descendant of `root` — the traversal guard. */
112
+ function isWithinRoot(root, candidate) {
113
+ const fromRoot = relative(root, candidate);
114
+ return Boolean(fromRoot)
115
+ && fromRoot !== '..'
116
+ && !fromRoot.startsWith('../')
117
+ && !fromRoot.startsWith('..\\');
118
+ }
119
+ /** Resolve only project-owned screen captures, under either capture root. */
96
120
  export function resolveProjectAssetRequest(projectRoot, requestUrl) {
97
121
  let pathname;
98
122
  try {
@@ -101,15 +125,45 @@ export function resolveProjectAssetRequest(projectRoot, requestUrl) {
101
125
  catch {
102
126
  return undefined;
103
127
  }
104
- if (!pathname.startsWith('/model/'))
128
+ if (!pathname.startsWith('/model/') && !pathname.startsWith('/assets/'))
105
129
  return undefined;
106
- const assetRoot = resolve(projectRoot, 'model', 'assets');
107
130
  const requested = resolve(projectRoot, pathname.replace(/^\//, ''));
108
- const fromAssetRoot = relative(assetRoot, requested);
109
- if (!fromAssetRoot || fromAssetRoot === '..' || fromAssetRoot.startsWith('../') || fromAssetRoot.startsWith('..\\')) {
110
- return undefined;
131
+ const within = CAPTURE_ROOTS.some(root => isWithinRoot(resolve(projectRoot, root), requested));
132
+ return within ? requested : undefined;
133
+ }
134
+ /**
135
+ * Report views that share an id.
136
+ *
137
+ * Kind collisions are already recorded this way (`KindNameCollision`) for the
138
+ * same reason: a `Map` keyed by a name silently keeps one writer, and which one
139
+ * survives then depends on file iteration order. A view is looked up by id from
140
+ * the URL, from the explorer and from every permalink, so a collision does not
141
+ * make views ambiguous — it makes the shadowed ones unreachable.
142
+ */
143
+ function reportDuplicateViewIds(diagrams) {
144
+ const byId = new Map();
145
+ for (const diagram of diagrams) {
146
+ const list = byId.get(diagram.id);
147
+ if (list)
148
+ list.push(diagram);
149
+ else
150
+ byId.set(diagram.id, [diagram]);
151
+ }
152
+ const collisions = [...byId.values()].filter(group => group.length > 1);
153
+ if (collisions.length === 0)
154
+ return;
155
+ const shadowed = collisions.reduce((sum, group) => sum + group.length - 1, 0);
156
+ console.error(`[Views] ${shadowed} view(s) are UNREACHABLE: ${collisions.length} id(s) are `
157
+ + 'declared by more than one view, and a view is addressed by its id. '
158
+ + 'Give each view its own `providedId`:');
159
+ for (const group of collisions) {
160
+ const reachable = group[0].shortId ?? group[0].name ?? group[0].id;
161
+ console.error(` - ${group[0].id} is claimed by ${group.length} views; only ${reachable} can be opened`);
162
+ for (const shadow of group.slice(1)) {
163
+ console.error(` shadowed: ${shadow.shortId ?? shadow.name ?? shadow.id}`
164
+ + `${shadow.sourceFile ? ` (${shadow.sourceFile})` : ''}`);
165
+ }
111
166
  }
112
- return requested;
113
167
  }
114
168
  export async function createDevServer(options) {
115
169
  const { port, webPackagePath, initialMessages, transformClientHtml } = options;
@@ -219,10 +273,19 @@ export async function createDevServer(options) {
219
273
  /** Serve captures referenced by project-relative ScreenCapture.imageUri. */
220
274
  function serveProjectAsset(req, res) {
221
275
  const requestUrl = req.url ?? '/';
222
- if (!requestUrl.split('?')[0].startsWith('/model/'))
276
+ const pathname = requestUrl.split('?')[0];
277
+ if (!pathname.startsWith('/model/') && !pathname.startsWith('/assets/'))
223
278
  return false;
224
279
  const requested = resolveProjectAssetRequest(options.projectRoot, requestUrl);
225
- if (!requested || !existsSync(requested) || !statSync(requested).isFile()) {
280
+ const owned = Boolean(requested) && existsSync(requested) && statSync(requested).isFile();
281
+ if (!owned) {
282
+ // `/assets/` is ALSO where the client's own hashed bundles live,
283
+ // and this handler runs before the web dist. Claiming every
284
+ // `/assets/` request would 404 the application's own JavaScript,
285
+ // so an unclaimed one falls through; `/model/` is unambiguous and
286
+ // still reports a missing capture as a missing capture.
287
+ if (pathname.startsWith('/assets/'))
288
+ return false;
226
289
  res.writeHead(404);
227
290
  res.end('Capture not found');
228
291
  return true;
@@ -321,20 +384,39 @@ export async function createDevServer(options) {
321
384
  const { WebSocketServer } = await import('ws');
322
385
  const wss = new WebSocketServer({ server });
323
386
  const clients = new Set();
324
- // User-created views are project state, not session state. Merge them into
325
- // the initial model payload so a UI screen created from the workbench is
326
- // still present after the dev server restarts.
327
- const initialModelIndex = initialMessages.findIndex(message => message.type === 'model:update');
328
- if (initialModelIndex >= 0) {
329
- const initialModel = initialMessages[initialModelIndex];
330
- const authored = initialModel.payload.diagrams ?? [];
331
- const authoredIds = new Set(authored.map(diagram => diagram.id));
332
- const userDiagrams = loadUserDiagrams(options.projectRoot).filter(diagram => !authoredIds.has(diagram.id));
333
- initialMessages[initialModelIndex] = {
334
- type: 'model:update',
335
- payload: { ...initialModel.payload, diagrams: [...authored, ...userDiagrams] },
336
- };
387
+ // Every view is DECLARED in SysML — see `view-writer.ts`. The sidecar at
388
+ // .memo/user-diagrams.json used to be merged in here as a second class of
389
+ // view, which is how a view could exist for Architect and for nothing else:
390
+ // no source file, so invisible to `memo validate`, to `syside check`, to
391
+ // review, and — wherever `.memo/` is ignored — to git.
392
+ //
393
+ // Rows left in that file are therefore not views but UNDECLARED views: an
394
+ // error to be fixed by declaring them, not a state to render. They are
395
+ // reported by id and not merged. The file is never rewritten or deleted
396
+ // here — those rows are the only record of what still needs declaring.
397
+ const undeclared = loadUserDiagrams(options.projectRoot);
398
+ if (undeclared.length > 0) {
399
+ const initialModel = initialMessages.find(m => m.type === 'model:update');
400
+ const declaredIds = new Set((initialModel?.payload.diagrams ?? []).map(diagram => diagram.id));
401
+ const orphans = undeclared.filter(diagram => !declaredIds.has(diagram.id));
402
+ if (orphans.length > 0) {
403
+ console.error(`[Views] ${orphans.length} view(s) in .memo/user-diagrams.json have no SysML declaration `
404
+ + 'and were NOT loaded. Declare each as a `view … { }` in source, then delete its row:');
405
+ for (const orphan of orphans) {
406
+ console.error(` - ${orphan.id} ${orphan.name ?? ''} (${orphan.viewKind ?? 'unknown kind'})`);
407
+ }
408
+ }
337
409
  }
410
+ // A view is addressed by its id, so two views sharing one is not a
411
+ // duplicate label — it is one view the product can never open. Every lookup
412
+ // returns whichever loaded first and the rest are shadowed silently, with
413
+ // no error anywhere, which is the worst way for a model to be wrong.
414
+ //
415
+ // Found in affera as 11 of 95 views unreachable: ten clinical-step views
416
+ // all declaring VIEW-AFR-OP-SOS-004, and a CIU interconnect view claiming
417
+ // the id of the CIU decomposition view, so opening it showed the other.
418
+ reportDuplicateViewIds(initialMessages.find(m => m.type === 'model:update')
419
+ ?.payload.diagrams ?? []);
338
420
  const currentDiagrams = () => {
339
421
  const model = initialMessages.find(m => m.type === 'model:update');
340
422
  return model?.payload.diagrams ?? [];
@@ -918,8 +1000,10 @@ export async function createDevServer(options) {
918
1000
  if (existsSync(path))
919
1001
  recordWriteTransaction(sourceFile, readFileSync(path, 'utf8'));
920
1002
  }
921
- // User-authored diagrams are sidecars rather than semantic
922
- // relationships, but they must not retain a stale element ID.
1003
+ // The only rows left in the sidecar are UNDECLARED views awaiting
1004
+ // migration; none is loaded as a view. They are still kept accurate,
1005
+ // because they are the record of what needs declaring and a dangling
1006
+ // element ID would carry straight into the declaration.
923
1007
  const diagrams = loadUserDiagrams(options.projectRoot);
924
1008
  let changed = false;
925
1009
  const cleaned = diagrams.map(diagram => {
@@ -1254,10 +1338,17 @@ export async function createDevServer(options) {
1254
1338
  const safeStem = rawStem
1255
1339
  .normalize('NFKD').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
1256
1340
  || 'capture';
1257
- const assetDir = resolve(options.projectRoot, 'model', 'assets', safeView);
1341
+ // Write into the layout the project actually uses. Creating
1342
+ // `model/` under a project whose source lives in `src/`
1343
+ // resurrects a directory that layout deliberately does not
1344
+ // have, and the capture would then sit outside the tree the
1345
+ // rest of the model is kept in.
1346
+ const captureRoot = existsSync(resolve(options.projectRoot, 'model'))
1347
+ ? resolve(options.projectRoot, 'model', 'assets')
1348
+ : resolve(options.projectRoot, 'assets');
1349
+ const assetDir = resolve(captureRoot, safeView);
1258
1350
  const assetPath = resolve(assetDir, `${safeStem}${extension}`);
1259
- const modelRoot = resolve(options.projectRoot, 'model');
1260
- if (relative(modelRoot, assetPath).startsWith('..'))
1351
+ if (!isWithinRoot(captureRoot, assetPath))
1261
1352
  throw new Error('Invalid capture path.');
1262
1353
  mkdirSync(assetDir, { recursive: true });
1263
1354
  writeFileSync(assetPath, bytes);
@@ -1276,29 +1367,60 @@ export async function createDevServer(options) {
1276
1367
  else if (msg.type === 'diagram:create') {
1277
1368
  const { projectRoot } = options;
1278
1369
  const diagram = { ...msg.payload, auto: false };
1279
- const userDiagrams = loadUserDiagrams(projectRoot);
1280
- userDiagrams.push(diagram);
1281
- saveUserDiagrams(projectRoot, userDiagrams);
1282
- broadcastDiagramChange(diagram, 'create');
1283
- console.log(`[Diagram] Created: ${diagram.name} (${diagram.id})`);
1370
+ // A view is model content, so it is DECLARED, not recorded in
1371
+ // a sidecar. Writing the SysML puts it where `memo validate`,
1372
+ // `syside check`, review and git can all see it, and — the
1373
+ // derived diagram then carrying a `sourceFile` — takes its
1374
+ // `.viewlayout` companion out of the hidden store too.
1375
+ const declared = await writeViewDeclaration(projectRoot, currentModel() ?? { elements: {}, diagrams: [] }, diagram);
1376
+ if (!declared.success) {
1377
+ // No fallback. A view that cannot be declared is not one
1378
+ // this project can hold, and recording it in a sidecar
1379
+ // would only hide that until something downstream needed
1380
+ // the declaration.
1381
+ const error = `The view "${diagram.name}" was not created: ${declared.reason}. `
1382
+ + 'Every view must be declared in SysML.';
1383
+ console.error(`[Diagram] REFUSED: ${error}`);
1384
+ ws.send(JSON.stringify({ type: 'app:error', payload: { requestId: msg.payload?.requestId, error } }));
1385
+ return;
1386
+ }
1387
+ console.log(`[Diagram] Declared: ${diagram.name} (${diagram.id}) as ${declared.identifier} in ${declared.filePath}`);
1388
+ // The file watcher recompiles and `view-deriver` emits the
1389
+ // diagram; no broadcast here, or clients would briefly hold
1390
+ // a second, sidecar-shaped copy of it.
1391
+ await recompileAfterWrite();
1284
1392
  }
1285
1393
  else if (msg.type === 'diagram:update') {
1394
+ // What a view shows lives in its `expose` members, not in a
1395
+ // list of ids in a sidecar, so the update is applied to the
1396
+ // declaration. This used to edit the sidecar, which meant
1397
+ // that for a DECLARED view it silently did nothing at all.
1286
1398
  const { projectRoot } = options;
1287
- const userDiagrams = loadUserDiagrams(projectRoot);
1288
- const idx = userDiagrams.findIndex(d => d.id === msg.payload.id);
1289
- if (idx >= 0) {
1290
- userDiagrams[idx] = { ...userDiagrams[idx], ...msg.payload };
1291
- saveUserDiagrams(projectRoot, userDiagrams);
1292
- broadcastDiagramChange(userDiagrams[idx], 'update');
1399
+ const declared = currentDiagrams().find(d => d.id === msg.payload?.id);
1400
+ const result = declared
1401
+ ? await updateViewExpose(projectRoot, currentModel() ?? { elements: {} }, declared, msg.payload?.elementIds)
1402
+ : { success: false, reason: `no view "${msg.payload?.id}" is declared` };
1403
+ if (result.success) {
1404
+ console.log(`[Diagram] Exposed ${result.exposed.join(', ')} on ${declared.name} (${result.filePath})`);
1405
+ await recompileAfterWrite();
1406
+ }
1407
+ else {
1408
+ const error = `The view "${declared?.name ?? msg.payload?.id}" was not changed: ${result.reason}.`;
1409
+ console.error(`[Diagram] REFUSED: ${error}`);
1410
+ ws.send(JSON.stringify({ type: 'app:error', payload: { requestId: msg.payload?.requestId, error } }));
1293
1411
  }
1294
1412
  }
1295
1413
  else if (msg.type === 'diagram:delete') {
1296
- const { projectRoot } = options;
1297
- const userDiagrams = loadUserDiagrams(projectRoot);
1298
- const filtered = userDiagrams.filter(d => d.id !== msg.payload.id);
1299
- saveUserDiagrams(projectRoot, filtered);
1300
- broadcastDiagramChange({ id: msg.payload.id }, 'delete');
1301
- console.log(`[Diagram] Deleted: ${msg.payload.id}`);
1414
+ // Deleting a view means deleting its declaration, which is
1415
+ // `element:delete` on the view element — not a sidecar edit.
1416
+ const declared = currentDiagrams().find(d => d.id === msg.payload?.id);
1417
+ const where = declared?.sourceFile
1418
+ ? `Remove its \`view\` declaration from ${declared.sourceFile}.`
1419
+ : 'It is not declared in SysML.';
1420
+ const error = `The view "${declared?.name ?? msg.payload?.id}" cannot be deleted from here — `
1421
+ + `a view exists because it is declared. ${where}`;
1422
+ console.error(`[Diagram] REFUSED: ${error}`);
1423
+ ws.send(JSON.stringify({ type: 'app:error', payload: { requestId: msg.payload?.requestId, error } }));
1302
1424
  }
1303
1425
  else if (msg.type === 'diagram:layout:update') {
1304
1426
  const { diagramId, layout } = msg.payload;