archgraph-argo 0.12.4 → 0.12.6

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.
@@ -113,6 +113,84 @@ function sha1(text) {
113
113
  return crypto.createHash('sha1').update(String(text)).digest('hex');
114
114
  }
115
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // Element child mirror (attributes -> t_attribute, testcases -> t_objecttests)
118
+ // Mirrors the legacy object-model import (import-from-kg.js applyElementAttributes /
119
+ // applyTestcases) so canonical attributes and acceptance tests are visible in EA
120
+ // under the element's Attributes / Testing tabs. Canonical-owned fields only:
121
+ // run status/results on tests are left untouched on update (EA-side state).
122
+ // ---------------------------------------------------------------------------
123
+ const MAX_ATTRIBUTE_DEFAULT_LENGTH = 250;
124
+ const TEST_CLASS_ACCEPTANCE = 4; // mirrors import-from-kg mapTestTypeToEaClass('Acceptance Test')
125
+
126
+ function attrRowGuid(elementAlias, name, occurrence) {
127
+ return deterministicGuid('attr:' + elementAlias + ':' + name + '#' + occurrence);
128
+ }
129
+ function attributeDefaultValue(attr) {
130
+ const value = attr.value === undefined || attr.value === null ? '' : String(attr.value);
131
+ return value.length > MAX_ATTRIBUTE_DEFAULT_LENGTH ? '' : value;
132
+ }
133
+ function attributeNoteText(attr) {
134
+ const parts = [];
135
+ const value = attr.value === undefined || attr.value === null ? '' : String(attr.value);
136
+ if (value.length > MAX_ATTRIBUTE_DEFAULT_LENGTH) { parts.push(value); }
137
+ if (attr.description !== undefined && attr.description !== null && String(attr.description) !== '') { parts.push(String(attr.description)); }
138
+ if (attr.content !== undefined && attr.content !== null && String(attr.content) !== '') { parts.push(String(attr.content)); }
139
+ return parts.join('\r\n\r\n');
140
+ }
141
+ function isPersistedAttribute(attr) {
142
+ return !!(attr && typeof attr === 'object' && typeof attr.name === 'string' && attr.name.trim() !== '' && attr.op !== 'remove');
143
+ }
144
+ function mirrorElementChildren(db, objectId, element, stats) {
145
+ const alias = String(element.id);
146
+ const attrs = (Array.isArray(element.attributes) ? element.attributes : []).filter(isPersistedAttribute);
147
+ if (attrs.length > 0) {
148
+ const existing = db.prepare('SELECT ID, Name, "Default", Notes, ea_guid, Pos FROM t_attribute WHERE Object_ID=?').all(objectId);
149
+ const available = existing.map((r) => ({ ...r }));
150
+ let maxPos = 0;
151
+ for (const r of existing) { const p = Number(r.Pos); if (!Number.isNaN(p) && p > maxPos) { maxPos = p; } }
152
+ const occurrence = {};
153
+ for (const a of attrs) {
154
+ occurrence[a.name] = (occurrence[a.name] || 0) + 1;
155
+ const guid = attrRowGuid(alias, a.name, occurrence[a.name]);
156
+ const notes = attributeNoteText(a);
157
+ const def = attributeDefaultValue(a);
158
+ const idx = available.findIndex((r) => String(r.ea_guid || '') === guid);
159
+ if (idx >= 0) {
160
+ db.prepare('UPDATE t_attribute SET Name=?, Type=?, "Default"=?, Notes=? WHERE ID=?')
161
+ .run(a.name, 'String', def, notes, Number(available[idx].ID));
162
+ available.splice(idx, 1);
163
+ stats.attributesUpdated++;
164
+ } else {
165
+ maxPos += 16;
166
+ db.prepare('INSERT INTO t_attribute (Object_ID, Name, Scope, Type, "Default", Notes, Pos, ea_guid) VALUES (?,?,?,?,?,?,?,?)')
167
+ .run(objectId, a.name, 'Public', 'String', def, notes, maxPos, guid);
168
+ stats.attributesAdded++;
169
+ }
170
+ }
171
+ }
172
+ const tests = Array.isArray(element.testcases) ? element.testcases : [];
173
+ for (const tc of tests) {
174
+ if (!tc || typeof tc.name !== 'string' || tc.name.trim() === '') { continue; }
175
+ const name = tc.name.trim();
176
+ const type = (tc.type !== undefined && tc.type !== null && String(tc.type) !== '') ? String(tc.type) : 'Acceptance Test';
177
+ const notes = tc.description === undefined || tc.description === null ? '' : String(tc.description);
178
+ const input = tc.Input === undefined || tc.Input === null ? '' : String(tc.Input);
179
+ const criteria = tc.acceptanceCriteria === undefined || tc.acceptanceCriteria === null ? '' : String(tc.acceptanceCriteria);
180
+ const existing = db.prepare('SELECT Test FROM t_objecttests WHERE Object_ID=? AND Test=? AND TestClass=?')
181
+ .get(objectId, name, TEST_CLASS_ACCEPTANCE);
182
+ if (existing) {
183
+ db.prepare('UPDATE t_objecttests SET TestType=?, Notes=?, InputData=?, AcceptanceCriteria=? WHERE Object_ID=? AND Test=? AND TestClass=?')
184
+ .run(type, notes, input, criteria, objectId, name, TEST_CLASS_ACCEPTANCE);
185
+ stats.testsUpdated++;
186
+ } else {
187
+ db.prepare('INSERT INTO t_objecttests (Object_ID, Test, TestClass, TestType, Notes, InputData, AcceptanceCriteria, Status, Results) VALUES (?,?,?,?,?,?,?,?,?)')
188
+ .run(objectId, name, TEST_CLASS_ACCEPTANCE, type, notes, input, criteria, 'Proposed', '');
189
+ stats.testsAdded++;
190
+ }
191
+ }
192
+ }
193
+
116
194
  // ---------------------------------------------------------------------------
117
195
  // sqlite helpers
118
196
  // ---------------------------------------------------------------------------
@@ -186,6 +264,17 @@ function parseStyleToken(styleEx, key) {
186
264
  const m = re.exec(text);
187
265
  return m ? m[2] : '';
188
266
  }
267
+ function ensureStyleToken(styleEx, keyValue) {
268
+ // EA rewrites StyleEx with its own tokens and drops unknown ones (e.g. schema_view_id).
269
+ // Re-inject our anchor while preserving EA's formatting tokens so diagram identity
270
+ // stays discoverable on the next sync.
271
+ const text = String(styleEx === null || styleEx === undefined ? '' : styleEx);
272
+ const key = String(keyValue).split('=')[0];
273
+ const existing = parseStyleToken(text, key);
274
+ if (existing !== '') { return text; }
275
+ const token = String(keyValue).indexOf('=') >= 0 ? String(keyValue) + ';' : String(keyValue) + '=;';
276
+ return text ? token + text : token;
277
+ }
189
278
 
190
279
  // ---------------------------------------------------------------------------
191
280
  // Core sync
@@ -335,6 +424,10 @@ function syncGraphToQea(graph, qeaPath, opts) {
335
424
  }
336
425
  tagStats.propsSkip = propsToWrite.length - newProps.length;
337
426
  }
427
+ // canonical attributes -> t_attribute, canonical testcases -> t_objecttests
428
+ for (const t of toTag) {
429
+ mirrorElementChildren(db, t.id, t.e, tagStats);
430
+ }
338
431
  }
339
432
  stages.elemTags = nowMs();
340
433
 
@@ -414,12 +507,20 @@ function syncGraphToQea(graph, qeaPath, opts) {
414
507
  stages.relTags = nowMs();
415
508
 
416
509
  // --- views/diagrams ----------------------------------------------------
510
+ // Diagram identity is matched by BOTH the schema_view_id StyleEx token AND the
511
+ // deterministic ea_guid. EA rewrites StyleEx (its own formatting tokens) whenever
512
+ // it touches an open project and DROPS unknown tokens like schema_view_id — if we
513
+ // only matched by the token we would re-INSERT the same deterministic ea_guid and
514
+ // crash on t_diagram's UNIQUE(ea_guid) (projection failure: Neo4j ok, EA stale).
417
515
  const existingDiags = db.prepare('SELECT Diagram_ID, Package_ID, Name, StyleEx, ea_guid FROM t_diagram WHERE Package_ID=?').all(syncId);
418
516
  const diagByView = new Map();
517
+ const diagByGuid = new Map();
419
518
  for (const d of existingDiags) {
519
+ if (d.ea_guid) { diagByGuid.set(String(d.ea_guid), d); }
420
520
  const v = parseStyleToken(d.StyleEx, 'schema_view_id');
421
521
  if (v) { diagByView.set(v, d); }
422
522
  }
523
+ const diagViewRows = new Map(); // view_id -> matched existing row (token OR guid)
423
524
  const newDiags = [];
424
525
  for (const view of graph.views || []) {
425
526
  if (!view || view.view_id === undefined || view.view_id === null) { continue; }
@@ -432,7 +533,7 @@ function syncGraphToQea(graph, qeaPath, opts) {
432
533
  }
433
534
  return 0;
434
535
  })();
435
- const existing = diagByView.get(viewId);
536
+ const existing = diagByView.get(viewId) || diagByGuid.get(deterministicGuid('diag:' + viewId)) || null;
436
537
  const intended = {
437
538
  Name: safeName(view.view_name, viewId),
438
539
  Diagram_Type: DIAGRAM_TYPE,
@@ -442,10 +543,15 @@ function syncGraphToQea(graph, qeaPath, opts) {
442
543
  StyleEx: styleEx,
443
544
  };
444
545
  if (existing) {
445
- const changed = intended.Name !== (existing.Name || '');
446
- if (DEBUG && changed) { console.error('DEBUG diagram chg', viewId, JSON.stringify({n:[intended.Name,(existing.Name||'')], notes:[intended.Notes,(existing.Notes||'')]})); }
546
+ diagViewRows.set(viewId, existing);
547
+ // EA may have rewritten StyleEx and dropped the anchor re-inject it while
548
+ // preserving EA's own formatting tokens so identity stays discoverable.
549
+ const anchoredStyleEx = ensureStyleToken(existing.StyleEx, 'schema_view_id=' + viewId);
550
+ const changed = intended.Name !== (existing.Name || '') || (existing.StyleEx || '') !== anchoredStyleEx;
551
+ if (DEBUG && changed) { console.error('DEBUG diagram chg', viewId, JSON.stringify({n:[intended.Name,(existing.Name||'')], style: !!parseStyleToken(existing.StyleEx,'schema_view_id')})); }
447
552
  if (changed && !o.dryRun) {
448
- updateRow(db, 't_diagram', ['Name'], 'Diagram_ID', Number(existing.Diagram_ID), intended);
553
+ db.prepare('UPDATE t_diagram SET Name=?, StyleEx=? WHERE Diagram_ID=?')
554
+ .run(intended.Name, anchoredStyleEx, Number(existing.Diagram_ID));
449
555
  }
450
556
  stats[changed ? 'updated' : 'skipped'].diagrams++;
451
557
  } else {
@@ -470,6 +576,12 @@ function syncGraphToQea(graph, qeaPath, opts) {
470
576
  } else {
471
577
  for (const d of newDiags) { diagAliasToId.set(parseStyleToken(d.StyleEx, 'schema_view_id'), -1); }
472
578
  }
579
+ const diagIdForView = (viewId) => {
580
+ const row = diagViewRows.get(viewId) || diagByView.get(viewId);
581
+ if (row) { return Number(row.Diagram_ID !== undefined ? row.Diagram_ID : row); }
582
+ const planned = diagAliasToId.get(viewId);
583
+ return planned === undefined ? null : planned;
584
+ };
473
585
  // view meta
474
586
  if (!o.dryRun) {
475
587
  for (const view of graph.views || []) {
@@ -480,10 +592,8 @@ function syncGraphToQea(graph, qeaPath, opts) {
480
592
  for (const view of graph.views || []) {
481
593
  if (!view || view.view_id === undefined || view.view_id === null) { continue; }
482
594
  const viewId = String(view.view_id);
483
- let dId = diagByView.get(viewId);
484
- if (!dId) { dId = diagAliasToId.get(viewId); }
485
- if (!dId) { continue; }
486
- const diagramId = Number(dId.Diagram_ID !== undefined ? dId.Diagram_ID : dId);
595
+ const diagramId = diagIdForView(viewId);
596
+ if (diagramId === null) { continue; }
487
597
  const placedObjs = new Set();
488
598
  const objs = db.prepare('SELECT Object_ID FROM t_diagramobjects WHERE Diagram_ID=?').all(diagramId);
489
599
  for (const r of objs) { placedObjs.add(Number(r.Object_ID)); }
@@ -1616,9 +1616,12 @@ async function buildMutationResult(context, mutations, write) {
1616
1616
  writeGraph(context.graphPath.absolutePath, mutationResult.document);
1617
1617
  result.written = true;
1618
1618
 
1619
- // WP2791: .qea projection parallel to the Neo4j trigger — non-fatal, best-effort.
1619
+ // WP2791: .qea projection parallel to the Neo4j trigger — non-fatal, best-effort,
1620
+ // but ALWAYS reported on the result (passed / failed / noop+reason) so a missing EA
1621
+ // update is never silent.
1620
1622
  {
1621
- const qeaTarget = resolveQeaProjectionTarget(context);
1623
+ const resolved = resolveQeaProjectionTarget(context);
1624
+ const qeaTarget = resolved && resolved.target;
1622
1625
  if (qeaTarget) {
1623
1626
  try {
1624
1627
  const projection = await runQeaProjection(qeaTarget);
@@ -1632,6 +1635,15 @@ async function buildMutationResult(context, mutations, write) {
1632
1635
  result.qeaProjection = { status: 'failed', error: String(error && error.message ? error.message : error) };
1633
1636
  result.warnings = addUnique(result.warnings || [], ['ea-qea projection error (non-fatal): ' + String(error && error.message ? error.message : error)]);
1634
1637
  }
1638
+ } else {
1639
+ result.qeaProjection = {
1640
+ status: 'noop',
1641
+ reason: (resolved && resolved.reason) || 'no .qea target',
1642
+ workspaceRoot: context.workspaceRoot,
1643
+ };
1644
+ if (resolved && resolved.hasEaSignals) {
1645
+ result.warnings = addUnique(result.warnings || [], ['ea-qea projection not run: ' + ((resolved && resolved.reason) || 'no .qea target')]);
1646
+ }
1635
1647
  }
1636
1648
  }
1637
1649
 
@@ -1837,26 +1849,46 @@ function summarizeDocument(document) {
1837
1849
  // the single *.qea at the workspace root (0/many -> no-op with an explicit log). NO config file.
1838
1850
  // Projection script runs from argo/scripts (same package as the MCP runtime), so a workspace
1839
1851
  // does not need to ship its own projection script (bundled argo/scripts module).
1840
- function resolveQeaProjectionTarget(context) {
1841
- try {
1842
- const workspaceRoot = String(context && context.workspaceRoot ? context.workspaceRoot : '');
1843
- if (!workspaceRoot || !fs.existsSync(workspaceRoot)) { return null; }
1844
- const graphAbsolute = context.graphPath && context.graphPath.absolutePath ? context.graphPath.absolutePath : null;
1845
- if (!graphAbsolute || !fs.existsSync(graphAbsolute)) { return null; }
1846
- const pick = (p) => (p && fs.existsSync(p) ? path.resolve(p) : null);
1847
- let qeaPath = pick(process.env.ARGO_EA_QEA);
1848
- if (qeaPath) { return { qeaPath, graphPath: graphAbsolute, workspaceRoot }; }
1849
- let qeas = [];
1850
- try { qeas = fs.readdirSync(workspaceRoot).filter((n) => n.toLowerCase().endsWith('.qea')); } catch { /* ignore */ }
1851
- if (qeas.length === 1) {
1852
- return { qeaPath: path.resolve(workspaceRoot, qeas[0]), graphPath: graphAbsolute, workspaceRoot };
1853
- }
1854
- console.log('[ea-qea] projection target: none' + (qeas.length > 1 ? ' (' + qeas.length + ' *.qea found; expected exactly one or ARGO_EA_QEA)' : '') + ' in ' + workspaceRoot);
1855
- return null;
1856
- } catch (error) {
1857
- console.log('[ea-qea] projection target resolution failed: ' + String(error && error.message ? error.message : error));
1858
- return null;
1859
- }
1852
+ function resolveQeaProjectionTarget(context) {
1853
+ const none = (reason, hasEaSignals) => ({ target: null, reason, hasEaSignals: !!hasEaSignals });
1854
+ try {
1855
+ const workspaceRoot = String(context && context.workspaceRoot ? context.workspaceRoot : '');
1856
+ if (!workspaceRoot || !fs.existsSync(workspaceRoot)) { return none('workspace root unavailable: ' + workspaceRoot, true); }
1857
+ const graphAbsolute = context.graphPath && context.graphPath.absolutePath ? context.graphPath.absolutePath : null;
1858
+ if (!graphAbsolute || !fs.existsSync(graphAbsolute)) { return none('canonical graph not found under ' + workspaceRoot, false); }
1859
+ const pick = (p) => (p && fs.existsSync(p) ? path.resolve(p) : null);
1860
+ const envTarget = process.env.ARGO_EA_QEA;
1861
+ let qeaPath = pick(envTarget);
1862
+ if (qeaPath) { return { target: { qeaPath, graphPath: graphAbsolute, workspaceRoot } }; }
1863
+ let qeas = [];
1864
+ try { qeas = fs.readdirSync(workspaceRoot).filter((n) => n.toLowerCase().endsWith('.qea')).sort(); } catch { /* ignore */ }
1865
+ if (qeas.length === 1) {
1866
+ return { target: { qeaPath: path.resolve(workspaceRoot, qeas[0]), graphPath: graphAbsolute, workspaceRoot } };
1867
+ }
1868
+ let eaFiles = [];
1869
+ try { eaFiles = fs.readdirSync(workspaceRoot).filter((n) => /\.(qea|feap|eap)$/i.test(n)).sort(); } catch { /* ignore */ }
1870
+ if (qeas.length > 1) {
1871
+ const reason = `${qeas.length} *.qea files found at the workspace root (${qeas.join(', ')}); expected exactly one or set ARGO_EA_QEA`;
1872
+ console.log('[ea-qea] projection target: none (' + reason + ') in ' + workspaceRoot);
1873
+ return none(reason, true);
1874
+ }
1875
+ const legacy = eaFiles.filter((n) => /\.(feap|eap)$/i.test(n));
1876
+ if (legacy.length > 0) {
1877
+ const reason = `EA model is a legacy ${legacy.join(', ')} (Firebird/Jet); the direct .qea projection cannot write it — convert to .qea (EA 17.2+) or set ARGO_EA_QEA to a .qea target`;
1878
+ console.log('[ea-qea] projection target: none (' + reason + ') in ' + workspaceRoot);
1879
+ return none(reason, true);
1880
+ }
1881
+ if (envTarget) {
1882
+ const reason = 'ARGO_EA_QEA points to a missing .qea file: ' + envTarget;
1883
+ console.log('[ea-qea] projection target: none (' + reason + ')');
1884
+ return none(reason, true);
1885
+ }
1886
+ console.log('[ea-qea] projection target: none in ' + workspaceRoot);
1887
+ return none('no .qea target found (set ARGO_EA_QEA or place exactly one *.qea at the workspace root)', false);
1888
+ } catch (error) {
1889
+ console.log('[ea-qea] projection target resolution failed: ' + String(error && error.message ? error.message : error));
1890
+ return none('EA .qea target resolution failed: ' + String(error && error.message ? error.message : error), true);
1891
+ }
1860
1892
  }
1861
1893
 
1862
1894
  function runQeaProjection(target) {
@@ -2167,6 +2199,26 @@ function compactMutationResponse(payload) {
2167
2199
  if (payload && payload.embeddingLifecycle && payload.embeddingLifecycle.state) {
2168
2200
  compact.embeddingLifecycle = { state: payload.embeddingLifecycle.state };
2169
2201
  }
2202
+ // Successful writes stay compact but downstream projection side effects (EA .qea,
2203
+ // Neo4j) and non-fatal warnings must stay observable — a caller deciding whether
2204
+ // "the EA file reflects this write" relies on qeaProjection.status (passed /
2205
+ // failed / noop + reason), never on silence.
2206
+ if (payload && payload.qeaProjection) {
2207
+ const q = payload.qeaProjection;
2208
+ if (q.status === 'passed') {
2209
+ compact.qeaProjection = { status: 'passed', ms: q.ms };
2210
+ } else if (q.status === 'noop') {
2211
+ compact.qeaProjection = { status: 'noop', reason: q.reason };
2212
+ } else {
2213
+ compact.qeaProjection = { status: q.status, error: q.error || q.reason };
2214
+ }
2215
+ }
2216
+ if (payload && payload.neo4jSync) {
2217
+ compact.neo4jSync = { status: payload.neo4jSync.status };
2218
+ }
2219
+ if (Array.isArray(payload && payload.warnings) && payload.warnings.length > 0) {
2220
+ compact.warnings = payload.warnings;
2221
+ }
2170
2222
  // Failed actual writes must retain business diagnostics (e.g. View15 maximum/observed)
2171
2223
  // so callers can distinguish reject reasons; successful writes stay compact.
2172
2224
  if (payload && payload.status === 'failed') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
5
  "license": "MIT",
6
6
  "bin": {