archgraph-argo 0.10.52 → 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.
@@ -1,7 +1,8 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
  const readline = require('node:readline');
4
- const { AsyncLocalStorage } = require('node:async_hooks');
4
+ const { AsyncLocalStorage } = require('node:async_hooks');
5
+ const { spawnSync } = require('node:child_process');
5
6
 
6
7
  const validatorMcp = require('./validator-mcp-server.js');
7
8
  const systemArchitectureMcp = require('./systemarchitecture-mcp-server.js');
@@ -395,7 +396,8 @@ async function callTool(name, args = {}, progressToken = null, dependencies = un
395
396
  mcp: report.mcp,
396
397
  systemArchitecture: report.systemArchitecture,
397
398
  subdiagramViews: report.subdiagramViews,
398
- neo4j: report.neo4j,
399
+ neo4j: report.neo4j,
400
+ qeaFullProjection: workspace.qeaFullProjection,
399
401
  semanticLifecycle: report.semanticLifecycle,
400
402
  semanticState: report.semanticLifecycle && report.semanticLifecycle.state,
401
403
  alignment: report.semanticLifecycle && report.semanticLifecycle.alignment,
@@ -418,6 +420,48 @@ async function withCanonicalSemanticInitTestComposition(composition, callback) {
418
420
  return canonicalSemanticInitStorage.run(Object.freeze({ ...composition }), callback);
419
421
  }
420
422
 
423
+ function resolveQeaProjectionTarget(workspaceRoot) {
424
+ try {
425
+ if (!workspaceRoot || !fs.existsSync(workspaceRoot)) { return null; }
426
+ const pick = (p) => (p && fs.existsSync(p) ? path.resolve(p) : null);
427
+ const qeaPath = pick(process.env.ARGO_EA_QEA);
428
+ if (qeaPath) { return { qeaPath, workspaceRoot }; }
429
+ let qeas = [];
430
+ try { qeas = fs.readdirSync(workspaceRoot).filter((n) => n.toLowerCase().endsWith('.qea')); } catch { /* ignore */ }
431
+ if (qeas.length === 1) { return { qeaPath: path.resolve(workspaceRoot, qeas[0]), workspaceRoot }; }
432
+ console.log('[ea-qea] init projection target: none' + (qeas.length > 1 ? ' (' + qeas.length + ' *.qea found; expected exactly one or ARGO_EA_QEA)' : '') + ' in ' + workspaceRoot);
433
+ return null;
434
+ } catch (error) {
435
+ console.log('[ea-qea] init projection target resolution failed: ' + String(error && error.message ? error.message : error));
436
+ return null;
437
+ }
438
+ }
439
+
440
+ // argo init .qea FULL projection (mirrors Neo4j initial full sync): wipes the whole target
441
+ // .qea then rebuilds it purely from the canonical graph. Non-fatal by contract.
442
+ function runQeaFullProjection(workspaceRoot, graphTargetPath) {
443
+ const target = resolveQeaProjectionTarget(workspaceRoot);
444
+ const script = path.join(__dirname, 'ea-qea-sync.js');
445
+ if (!target || !fs.existsSync(script)) {
446
+ return { status: 'noop', reason: !target ? 'no .qea target (env ARGO_EA_QEA or exactly one root *.qea)' : 'argo/scripts/ea-qea-sync.js missing' };
447
+ }
448
+ const snapshotDir = path.join(workspaceRoot, '.argo', 'temp', 'qea-backups');
449
+ const args = [script, '--mode', 'full', '--graph', graphTargetPath, '--qea', target.qeaPath, '--snapshot-dir', snapshotDir];
450
+ const started = Date.now();
451
+ try {
452
+ const res = spawnSync(process.execPath, args, { cwd: workspaceRoot, encoding: 'utf8', windowsHide: true, timeout: 120000 });
453
+ const ok = res.status === 0;
454
+ return {
455
+ status: ok ? 'ok' : 'failed',
456
+ qea: target.qeaPath,
457
+ ms: Date.now() - started,
458
+ ...(ok ? {} : { error: String((res.stderr || '').slice(0, 600) || ('exit ' + res.status)) }),
459
+ };
460
+ } catch (error) {
461
+ return { status: 'failed', qea: target.qeaPath, ms: Date.now() - started, error: String(error && error.message ? error.message : error) };
462
+ }
463
+ }
464
+
421
465
  async function initializeWorkspace(workspaceRoot) {
422
466
  const workspaceName = path.basename(workspaceRoot);
423
467
  const createdFiles = [];
@@ -454,8 +498,17 @@ async function initializeWorkspace(workspaceRoot) {
454
498
  }
455
499
  }
456
500
 
501
+ // WP2791: init .qea FULL projection (parallel to Neo4j initial full sync in the harness
502
+ // report). Non-fatal: a qea failure must never fail workspace init.
503
+ let qeaFullProjection = null;
504
+ try {
505
+ qeaFullProjection = runQeaFullProjection(workspaceRoot, graphTargetPath);
506
+ } catch (error) {
507
+ qeaFullProjection = { status: 'failed', error: String(error && error.message ? error.message : error) };
508
+ }
457
509
  return {
458
- workspaceRoot,
510
+ workspaceRoot,
511
+ qeaFullProjection,
459
512
  targetFeapName,
460
513
  createdFiles,
461
514
  updatedFiles,
@@ -0,0 +1,688 @@
1
+ 'use strict';
2
+
3
+ // EA .qea (SQLite) direct projection library — WP2791.
4
+ // Zero third-party deps: node:sqlite (DatabaseSync, Node >=22/24), node:crypto, node:fs.
5
+ // Mirrors the ArchiMate->EA mapping conventions of eatool/EA-jsscript/import-from-kg.js
6
+ // (Object_Type base + Stereotype archimate name + Alias/ea_guid anchors + schema_view_id
7
+ // StyleEx token) while writing through SQLite directly (no EA COM required).
8
+ //
9
+ // Concurrency model:
10
+ // - PRAGMA busy_timeout = 15000 set on every connection; write phases wrapped in short
11
+ // transactions with a bounded busy retry. Keep EA's own SQLite handle open is fine —
12
+ // SQLite only locks during active transactions, an idle open connection does not block.
13
+ // - Rows are matched by Alias (schema id) / deterministic ea_guid, update-in-place only;
14
+ // existing t_diagramobjects/t_diagramlinks geometry is NEVER updated or deleted, only
15
+ // missing members are INSERTed.
16
+
17
+ const { DatabaseSync } = require('node:sqlite');
18
+ const DEBUG = !!process.env.EA_QEA_DEBUG;
19
+ const crypto = require('node:crypto');
20
+
21
+ const SYNC_PACKAGE_NAME = 'ArchGraph Sync';
22
+ const DIAGRAM_TYPE = 'Logical';
23
+ const META_TABLE = 'kg_sync_meta'; // {kind,key,sha,payload} — Node export/reconcile store
24
+ const BUSY_TIMEOUT_MS = 15000;
25
+ const CHUNK = 200;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // ArchiMate -> EA mapping (mirrors import-from-kg.js)
29
+ // ---------------------------------------------------------------------------
30
+ function normalizeName(value) {
31
+ const text = String(value === null || value === undefined ? '' : value);
32
+ return text.replace(/^ArchiMate[_\s-]*/i, '');
33
+ }
34
+ function alnum(value) {
35
+ return String(value === null || value === undefined ? '' : value)
36
+ .replace(/&/g, '')
37
+ .replace(/[^A-Za-z0-9]/g, '');
38
+ }
39
+ function elementStereotype(type) {
40
+ const norm = alnum(normalizeName(type));
41
+ if (norm === '') { return ''; }
42
+ if (norm === 'SystemSoftware') { return 'ArchiMate_SystemSoftware'; }
43
+ if (norm === 'Constraint') { return 'ArchiMate_Constraint'; }
44
+ return norm;
45
+ }
46
+ const ACTIVITY_ALNUM = new Set([
47
+ 'BusinessEvent', 'BusinessProcess', 'BusinessFunction', 'BusinessInteraction',
48
+ 'BusinessService', 'ApplicationEvent', 'ApplicationProcess', 'ApplicationFunction',
49
+ 'ApplicationInteraction', 'ApplicationService', 'TechnologyEvent',
50
+ 'TechnologyProcess', 'TechnologyFunction', 'TechnologyInteraction',
51
+ 'TechnologyService', 'ValueStream',
52
+ ]);
53
+ function elementObjectType(type) {
54
+ const norm = alnum(normalizeName(type));
55
+ if (norm === 'ApplicationComponent') { return 'Component'; }
56
+ if (ACTIVITY_ALNUM.has(norm)) { return 'Activity'; }
57
+ if (norm === 'Junction' || norm === 'AndJunction' || norm === 'OrJunction') { return 'StateNode'; }
58
+ return 'Class';
59
+ }
60
+ function canonicalArchimateType(type) {
61
+ const norm = alnum(normalizeName(type));
62
+ return norm; // full ArchiMate name e.g. BusinessActor is carried by Stereotype column
63
+ }
64
+ function relStereotype(type) {
65
+ return alnum(normalizeName(type));
66
+ }
67
+ function relationshipMap(type) {
68
+ const norm = alnum(normalizeName(type));
69
+ const meta = { connectorType: 'Association', directed: false };
70
+ switch (norm) {
71
+ case 'Composition':
72
+ case 'Aggregation':
73
+ meta.connectorType = 'Association';
74
+ break;
75
+ case 'Specialization':
76
+ meta.connectorType = 'Generalization';
77
+ meta.directed = true;
78
+ break;
79
+ case 'Realization':
80
+ case 'Access':
81
+ meta.connectorType = 'Dependency';
82
+ meta.directed = true;
83
+ break;
84
+ case 'Serving':
85
+ case 'Assignment':
86
+ meta.connectorType = 'Association';
87
+ meta.directed = true;
88
+ break;
89
+ case 'Association':
90
+ meta.connectorType = 'Association';
91
+ break;
92
+ case 'Triggering':
93
+ case 'Flow':
94
+ case 'Influence':
95
+ meta.connectorType = 'ControlFlow';
96
+ meta.directed = true;
97
+ break;
98
+ default:
99
+ meta.connectorType = 'Association';
100
+ break;
101
+ }
102
+ return meta;
103
+ }
104
+ function safeName(name, fallbackId) {
105
+ const n = String(name === null || name === undefined ? '' : name).trim();
106
+ return n !== '' ? n : String(fallbackId === null || fallbackId === undefined ? '' : fallbackId);
107
+ }
108
+ function deterministicGuid(seed) {
109
+ const h = crypto.createHash('sha1').update(String(seed)).digest('hex');
110
+ return '{' + h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20, 32) + '}';
111
+ }
112
+ function sha1(text) {
113
+ return crypto.createHash('sha1').update(String(text)).digest('hex');
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // sqlite helpers
118
+ // ---------------------------------------------------------------------------
119
+ function openQea(file) {
120
+ const db = new DatabaseSync(file);
121
+ db.exec('PRAGMA busy_timeout=' + BUSY_TIMEOUT_MS);
122
+ db.exec('PRAGMA foreign_keys=OFF');
123
+ return db;
124
+ }
125
+ function columnNames(db, table) {
126
+ const rows = db.prepare('PRAGMA table_info(' + table + ')').all();
127
+ return rows.map((r) => r.name);
128
+ }
129
+ function ensureMetaTable(db) {
130
+ db.exec('CREATE TABLE IF NOT EXISTS ' + META_TABLE + ' (kind TEXT NOT NULL, key TEXT NOT NULL, sha TEXT, payload TEXT, PRIMARY KEY(kind,key))');
131
+ }
132
+ // multi-row INSERT with positional placeholders, chunked
133
+ function insertMany(db, table, columns, rows) {
134
+ let inserted = 0;
135
+ if (!rows || rows.length === 0) { return 0; }
136
+ for (let i = 0; i < rows.length; i += CHUNK) {
137
+ const chunk = rows.slice(i, i + CHUNK);
138
+ const colSql = columns.join(', ');
139
+ const valueSql = chunk.map(() => '(' + columns.map(() => '?').join(', ') + ')').join(', ');
140
+ const sql = 'INSERT INTO ' + table + ' (' + colSql + ') VALUES ' + valueSql;
141
+ const params = [];
142
+ for (const r of chunk) { for (const c of columns) { params.push(r[c]); } }
143
+ db.prepare(sql).run(...params);
144
+ inserted += chunk.length;
145
+ }
146
+ return inserted;
147
+ }
148
+ function updateRow(db, table, setColumns, whereColumn, id, values) {
149
+ const setSql = setColumns.map((c) => c + '=?').join(', ');
150
+ db.prepare('UPDATE ' + table + ' SET ' + setSql + ' WHERE ' + whereColumn + '=?')
151
+ .run(...setColumns.map((c) => values[c]), id);
152
+ }
153
+ function upsertMeta(db, kind, key, node) {
154
+ const payload = JSON.stringify(node);
155
+ const sh = sha1(payload);
156
+ db.prepare('INSERT INTO ' + META_TABLE + ' (kind,key,sha,payload) VALUES (?,?,?,?) ON CONFLICT(kind,key) DO UPDATE SET sha=excluded.sha, payload=excluded.payload')
157
+ .run(kind, String(key), sh, payload);
158
+ return sh;
159
+ }
160
+ function readMetaKind(db, kind) {
161
+ const rows = db.prepare('SELECT key, payload FROM ' + META_TABLE + ' WHERE kind=? ORDER BY key').all(kind);
162
+ const out = [];
163
+ for (const r of rows) { try { out.push(JSON.parse(r.payload)); } catch { /* skip corrupt */ } }
164
+ return out;
165
+ }
166
+ // mark checkpoints
167
+ function nowMs() { return Date.now(); }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Package anchoring
171
+ // ---------------------------------------------------------------------------
172
+ function resolveSyncPackage(db, dryRun) {
173
+ let roots = db.prepare('SELECT Package_ID FROM t_package WHERE Parent_ID=0 ORDER BY Package_ID').all();
174
+ if (roots.length === 0) { roots = db.prepare('SELECT Package_ID FROM t_package ORDER BY Package_ID LIMIT 1').all(); }
175
+ const rootId = Number(roots[0].Package_ID);
176
+ let existing = db.prepare('SELECT Package_ID, Name FROM t_package WHERE Parent_ID=? AND Name=?').get(rootId, SYNC_PACKAGE_NAME);
177
+ if (existing) { return { rootId, packageId: Number(existing.Package_ID), created: false }; }
178
+ if (dryRun) { return { rootId, packageId: 0, created: true }; }
179
+ const guid = deterministicGuid('pkg:' + SYNC_PACKAGE_NAME);
180
+ const r = db.prepare('INSERT INTO t_package (Name, Parent_ID, ea_guid, Notes) VALUES (?,?,?,?)').run(SYNC_PACKAGE_NAME, rootId, guid, '');
181
+ return { rootId, packageId: Number(r.lastInsertRowid), created: true };
182
+ }
183
+ function parseStyleToken(styleEx, key) {
184
+ const text = String(styleEx === null || styleEx === undefined ? '' : styleEx);
185
+ const re = new RegExp('(^|;|\\s)' + key + '=([^;]*)', 'i');
186
+ const m = re.exec(text);
187
+ return m ? m[2] : '';
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Core sync
192
+ // ---------------------------------------------------------------------------
193
+ // opts: { dryRun, allowDelete, snapshotDir }
194
+ function syncGraphToQea(graph, qeaPath, opts) {
195
+ const o = opts || {};
196
+ const stages = {};
197
+ const stats = {
198
+ added: { elements: 0, relationships: 0, diagrams: 0, diagramObjects: 0, diagramLinks: 0 },
199
+ updated: { elements: 0, relationships: 0, diagrams: 0 },
200
+ skipped: { elements: 0, relationships: 0, diagrams: 0 },
201
+ deleteCandidates: 0, deleted: 0,
202
+ };
203
+ const t0 = nowMs();
204
+ const db = openQea(qeaPath);
205
+ try {
206
+ ensureMetaTable(db);
207
+ if (!o.dryRun) { db.exec('BEGIN IMMEDIATE'); }
208
+ stages.package = nowMs();
209
+
210
+ const syncPkg = resolveSyncPackage(db, o.dryRun);
211
+ const syncId = syncPkg.packageId;
212
+ stats.syncPackageId = syncId;
213
+
214
+ // --- elements ---------------------------------------------------------
215
+ const elemById = new Map();
216
+ for (const e of graph.elements || []) { if (e && e.id !== undefined && e.id !== null) { elemById.set(String(e.id), e); } }
217
+ const existingElems = db.prepare(
218
+ 'SELECT Object_ID, Alias, ea_guid, Object_Type, Name, Stereotype, Note, Status, Package_ID, ParentID FROM t_object WHERE Package_ID=?').all(syncId);
219
+ const elemByAlias = new Map();
220
+ const elemByGuid = new Map();
221
+ for (const row of existingElems) {
222
+ if (row.Alias) { elemByAlias.set(String(row.Alias), row); }
223
+ if (row.ea_guid) { elemByGuid.set(String(row.ea_guid), row); }
224
+ }
225
+ const newElems = [];
226
+ const parentOf = {};
227
+ for (const e of graph.elements || []) {
228
+ if (!e || e.id === undefined || e.id === null) { continue; }
229
+ const alias = String(e.id);
230
+ const guid = deterministicGuid('el:' + alias);
231
+ const existing = elemByAlias.get(alias) || elemByGuid.get(guid) || null;
232
+ const parentId = e.parent !== undefined && e.parent !== null && String(e.parent) !== '0' && elemById.has(String(e.parent)) ? parentOf[String(e.parent)] || 0 : 0;
233
+ const intended = {
234
+ Object_Type: elementObjectType(e.type),
235
+ Name: safeName(e.name, alias),
236
+ Stereotype: elementStereotype(e.type),
237
+ Note: String(e.description === null || e.description === undefined ? '' : e.description),
238
+ Status: String(e.status === null || e.status === undefined ? 'Proposed' : e.status),
239
+ ParentID: parentId || 0,
240
+ };
241
+ if (existing) {
242
+ const changed = intended.Object_Type !== existing.Object_Type || intended.Name !== existing.Name ||
243
+ (intended.Stereotype || '') !== (existing.Stereotype || '') || intended.Note !== (existing.Note || '') ||
244
+ (intended.Status || '') !== (existing.Status || '') || Number(intended.ParentID) !== Number(existing.ParentID || 0);
245
+ if (changed && !o.dryRun) {
246
+ updateRow(db, 't_object', ['Name', 'Stereotype', 'Note', 'Status', 'ParentID'], 'Object_ID', Number(existing.Object_ID), intended);
247
+ }
248
+ stats[changed ? 'updated' : 'skipped'].elements++;
249
+ parentOf[alias] = Number(existing.Object_ID);
250
+ } else {
251
+ newElems.push({ ...intended, Alias: alias, ea_guid: guid, Package_ID: syncId });
252
+ stats.added.elements++;
253
+ }
254
+ }
255
+ // batch insert new elements (ParentID refined below) then read ids
256
+ if (newElems.length > 0) {
257
+ if (!o.dryRun) {
258
+ insertMany(db, 't_object', ['Object_Type', 'Name', 'Stereotype', 'Note', 'Status', 'Alias', 'ea_guid', 'Package_ID', 'ParentID'], newElems);
259
+ }
260
+ for (const row of newElems) { parentOf[row.Alias] = row.Alias; } // placeholder; resolved by readback
261
+ }
262
+ // resolve real Object_ID for new elements + parents
263
+ const aliasToId = new Map();
264
+ if (!o.dryRun) {
265
+ const aliases = newElems.map((r) => r.Alias);
266
+ const readback = [];
267
+ for (let i = 0; i < aliases.length; i += 200) {
268
+ const part = aliases.slice(i, i + 200);
269
+ const marks = part.map(() => '?').join(',');
270
+ const rows = db.prepare('SELECT Object_ID, Alias FROM t_object WHERE Alias IN (' + marks + ')').all(...part);
271
+ readback.push(...rows);
272
+ }
273
+ for (const r of readback) { aliasToId.set(String(r.Alias), Number(r.Object_ID)); }
274
+ } else {
275
+ for (const r of newElems) { aliasToId.set(r.Alias, -1); }
276
+ }
277
+ // second pass: set ParentID where the parent is a new element
278
+ if (newElems.length > 0 && !o.dryRun) {
279
+ for (const e of graph.elements || []) {
280
+ if (!e || e.id === undefined || e.id === null) { continue; }
281
+ const alias = String(e.id);
282
+ const pid = aliasToId.get(alias);
283
+ if (pid === undefined) { continue; }
284
+ const parentNode = e.parent !== undefined && e.parent !== null && elemById.get(String(e.parent)) ? elemById.get(String(e.parent)) : null;
285
+ if (!parentNode) { continue; }
286
+ const parentRowId = (function () {
287
+ const a = String(e.parent);
288
+ const ex = elemByAlias.get(a) || elemByGuid.get(deterministicGuid('el:' + a));
289
+ if (ex) { return Number(ex.Object_ID); }
290
+ return aliasToId.get(a);
291
+ })();
292
+ if (parentRowId && Number(parentRowId) !== pid) {
293
+ db.prepare('UPDATE t_object SET ParentID=? WHERE Object_ID=?').run(Number(parentRowId), pid);
294
+ }
295
+ }
296
+ }
297
+ stages.elements = nowMs();
298
+
299
+ // element anchors + meta (idempotent, fingerprint-skipped)
300
+ let tagStats = { propsNew: 0, propsSkip: 0 };
301
+ if (!o.dryRun) {
302
+ // minimal EA-facing anchors for compatibility with the legacy object-model exporter
303
+ const anchorRows = [];
304
+ const elemIdByAliasFinal = new Map();
305
+ for (const ex of existingElems) { elemIdByAliasFinal.set(String(ex.Alias), Number(ex.Object_ID)); }
306
+ for (const [a, id] of aliasToId) { elemIdByAliasFinal.set(a, id); }
307
+ const toTag = [];
308
+ for (const e of graph.elements || []) {
309
+ if (!e || e.id === undefined || e.id === null) { continue; }
310
+ const id = elemIdByAliasFinal.get(String(e.id));
311
+ if (id === undefined) { continue; }
312
+ toTag.push({ id: Number(id), e });
313
+ }
314
+ const metaNew = [];
315
+ const propsToWrite = [];
316
+ for (const t of toTag) {
317
+ const sh = upsertMeta(db, 'element', t.e.id, t.e);
318
+ metaNew.push(1);
319
+ propsToWrite.push([t.id, 'schema_id', t.e.id]);
320
+ propsToWrite.push([t.id, 'archimate_type', canonicalArchimateType(t.e.type)]);
321
+ }
322
+ if (propsToWrite.length > 0) {
323
+ const existingProps = new Set();
324
+ for (let i = 0; i < propsToWrite.length; i += 200) {
325
+ const part = propsToWrite.slice(i, i + 200);
326
+ const ids = new Set(part.map((p) => p[0]));
327
+ const marks = Array.from(ids).map(() => '?').join(',');
328
+ const rows = db.prepare('SELECT Object_ID, Property FROM t_objectproperties WHERE Object_ID IN (' + marks + ')').all(...Array.from(ids));
329
+ for (const r of rows) { existingProps.add(Number(r.Object_ID) + '|' + r.Property); }
330
+ }
331
+ const newProps = propsToWrite.filter((p) => !existingProps.has(p[0] + '|' + p[1]));
332
+ if (newProps.length > 0) {
333
+ insertMany(db, 't_objectproperties', ['Object_ID', 'Property', 'Value', 'Notes'], newProps.map((p) => ({ Object_ID: p[0], Property: p[1], Value: p[2], Notes: '' })));
334
+ tagStats.propsNew += newProps.length;
335
+ }
336
+ tagStats.propsSkip = propsToWrite.length - newProps.length;
337
+ }
338
+ }
339
+ stages.elemTags = nowMs();
340
+
341
+ // --- relationships -----------------------------------------------------
342
+ const elemIdByAliasAll = new Map();
343
+ for (const ex of existingElems) { if (ex.Alias) { elemIdByAliasAll.set(String(ex.Alias), Number(ex.Object_ID)); } }
344
+ if (!o.dryRun) { for (const [a, id] of aliasToId) { elemIdByAliasAll.set(a, id); } }
345
+ const relByGuid = new Map();
346
+ const existingRels = db.prepare('SELECT Connector_ID, ea_guid, Name, Connector_Type, Stereotype, Notes, Direction, Start_Object_ID, End_Object_ID FROM t_connector').all();
347
+ for (const r of existingRels) { if (r.ea_guid) { relByGuid.set(String(r.ea_guid), r); } }
348
+ const newRels = [];
349
+ for (const rel of graph.relationships || []) {
350
+ if (!rel || rel.id === undefined || rel.id === null) { continue; }
351
+ const alias = String(rel.id);
352
+ const guid = deterministicGuid('rel:' + alias);
353
+ const start = elemIdByAliasAll.get(String(rel.source_id));
354
+ const end = elemIdByAliasAll.get(String(rel.target_id));
355
+ if (start === undefined || end === undefined) { continue; }
356
+ const map = relationshipMap(rel.type);
357
+ const direction = map.directed ? 'Source -> Destination' : '';
358
+ const existing = relByGuid.get(guid);
359
+ const intended = {
360
+ Name: safeName(rel.name, alias),
361
+ Connector_Type: map.connectorType,
362
+ Stereotype: relStereotype(rel.type),
363
+ Notes: String(rel.description === null || rel.description === undefined ? '' : rel.description),
364
+ Direction: direction,
365
+ Start_Object_ID: Number(start),
366
+ End_Object_ID: Number(end),
367
+ };
368
+ if (existing) {
369
+ const changed = intended.Name !== (existing.Name || '') || (intended.Connector_Type || '') !== (existing.Connector_Type || '') ||
370
+ (intended.Stereotype || '') !== (existing.Stereotype || '') || intended.Notes !== (existing.Notes || '') ||
371
+ intended.Direction !== (existing.Direction || '') || Number(intended.Start_Object_ID) !== Number(existing.Start_Object_ID || 0) ||
372
+ Number(intended.End_Object_ID) !== Number(existing.End_Object_ID || 0);
373
+ if (changed && !o.dryRun) {
374
+ updateRow(db, 't_connector', ['Name', 'Connector_Type', 'Stereotype', 'Notes', 'Direction', 'Start_Object_ID', 'End_Object_ID'], 'Connector_ID', Number(existing.Connector_ID), intended);
375
+ }
376
+ stats[changed ? 'updated' : 'skipped'].relationships++;
377
+ } else {
378
+ newRels.push({ ...intended, ea_guid: guid, _alias: alias });
379
+ stats.added.relationships++;
380
+ }
381
+ }
382
+ const relAliasToId = new Map();
383
+ if (!o.dryRun) {
384
+ if (newRels.length > 0) {
385
+ insertMany(db, 't_connector', ['Name', 'Connector_Type', 'Stereotype', 'Notes', 'Direction', 'Start_Object_ID', 'End_Object_ID', 'ea_guid'], newRels);
386
+ }
387
+ for (let i = 0; i < newRels.length; i += 200) {
388
+ const part = newRels.slice(i, i + 200);
389
+ const marks = part.map(() => '?').join(',');
390
+ const rows = db.prepare('SELECT Connector_ID, ea_guid FROM t_connector WHERE ea_guid IN (' + marks + ')').all(...part.map((r) => r.ea_guid));
391
+ for (const r of rows) { const pr = part.find((x) => x.ea_guid === r.ea_guid); if (pr) { relAliasToId.set(pr._alias, Number(r.Connector_ID)); } }
392
+ }
393
+ } else {
394
+ for (const r of newRels) { relAliasToId.set(r._alias, -1); }
395
+ }
396
+ stages.relationships = nowMs();
397
+
398
+ // connector anchors + meta
399
+ if (!o.dryRun) {
400
+ for (const rel of graph.relationships || []) {
401
+ if (!rel || rel.id === undefined || rel.id === null) { continue; }
402
+ const id = relAliasToId.get(String(rel.id));
403
+ if (id === undefined || id < 0) { continue; }
404
+ upsertMeta(db, 'relationship', rel.id, rel);
405
+ const existingCt = db.prepare('SELECT PropertyID FROM t_connectortag WHERE ElementID=? AND Property=?').get(Number(id), 'schema_id');
406
+ if (!existingCt) {
407
+ db.prepare('INSERT INTO t_connectortag (ElementID, Property, VALUE, NOTES) VALUES (?,?,?,?)')
408
+ .run(Number(id), 'schema_id', rel.id, '');
409
+ db.prepare('INSERT INTO t_connectortag (ElementID, Property, VALUE, NOTES) VALUES (?,?,?,?)')
410
+ .run(Number(id), 'archimate_relationship_type', canonicalArchimateType(rel.type), '');
411
+ }
412
+ }
413
+ }
414
+ stages.relTags = nowMs();
415
+
416
+ // --- views/diagrams ----------------------------------------------------
417
+ const existingDiags = db.prepare('SELECT Diagram_ID, Package_ID, Name, StyleEx, ea_guid FROM t_diagram WHERE Package_ID=?').all(syncId);
418
+ const diagByView = new Map();
419
+ for (const d of existingDiags) {
420
+ const v = parseStyleToken(d.StyleEx, 'schema_view_id');
421
+ if (v) { diagByView.set(v, d); }
422
+ }
423
+ const newDiags = [];
424
+ for (const view of graph.views || []) {
425
+ if (!view || view.view_id === undefined || view.view_id === null) { continue; }
426
+ const viewId = String(view.view_id);
427
+ const styleEx = 'schema_view_id=' + viewId + ';';
428
+ const parentObjectId = (function () {
429
+ if (view.parent_element_id !== undefined && view.parent_element_id !== null && view.parent_element_id !== '') {
430
+ const pid = elemIdByAliasAll.get(String(view.parent_element_id));
431
+ if (pid !== undefined) { return pid; }
432
+ }
433
+ return 0;
434
+ })();
435
+ const existing = diagByView.get(viewId);
436
+ const intended = {
437
+ Name: safeName(view.view_name, viewId),
438
+ Diagram_Type: DIAGRAM_TYPE,
439
+ Package_ID: syncId,
440
+ ParentID: parentObjectId,
441
+ Notes: '', // EA .qea 不保留多段 Notes;视图内容经 kg_sync_meta 保真
442
+ StyleEx: styleEx,
443
+ };
444
+ 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||'')]})); }
447
+ if (changed && !o.dryRun) {
448
+ updateRow(db, 't_diagram', ['Name'], 'Diagram_ID', Number(existing.Diagram_ID), intended);
449
+ }
450
+ stats[changed ? 'updated' : 'skipped'].diagrams++;
451
+ } else {
452
+ newDiags.push({ ...intended, ea_guid: deterministicGuid('diag:' + viewId) });
453
+ stats.added.diagrams++;
454
+ }
455
+ }
456
+ const diagAliasToId = new Map();
457
+ if (!o.dryRun) {
458
+ if (newDiags.length > 0) {
459
+ insertMany(db, 't_diagram', ['Name', 'Diagram_Type', 'Package_ID', 'ParentID', 'StyleEx', 'ea_guid'], newDiags);
460
+ }
461
+ for (let i = 0; i < newDiags.length; i += 200) {
462
+ const part = newDiags.slice(i, i + 200);
463
+ const marks = part.map(() => '?').join(',');
464
+ const rows = db.prepare('SELECT Diagram_ID, StyleEx FROM t_diagram WHERE StyleEx IN (' + marks + ')').all(...part.map((d) => d.StyleEx));
465
+ for (const r of rows) {
466
+ const v = parseStyleToken(r.StyleEx, 'schema_view_id');
467
+ if (v) { diagAliasToId.set(v, Number(r.Diagram_ID)); }
468
+ }
469
+ }
470
+ } else {
471
+ for (const d of newDiags) { diagAliasToId.set(parseStyleToken(d.StyleEx, 'schema_view_id'), -1); }
472
+ }
473
+ // view meta
474
+ if (!o.dryRun) {
475
+ for (const view of graph.views || []) {
476
+ if (view && view.view_id !== undefined && view.view_id !== null) { upsertMeta(db, 'view', view.view_id, view); }
477
+ }
478
+ }
479
+ // memberships: only INSERT missing; never touch existing geometry
480
+ for (const view of graph.views || []) {
481
+ if (!view || view.view_id === undefined || view.view_id === null) { continue; }
482
+ 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);
487
+ const placedObjs = new Set();
488
+ const objs = db.prepare('SELECT Object_ID FROM t_diagramobjects WHERE Diagram_ID=?').all(diagramId);
489
+ for (const r of objs) { placedObjs.add(Number(r.Object_ID)); }
490
+ const nextSeq = objs.length;
491
+ const newObjs = [];
492
+ const incl = view.included_elements || [];
493
+ let seq = nextSeq;
494
+ for (const elId of incl) {
495
+ const oid = elemIdByAliasAll.get(String(elId));
496
+ if (oid === undefined) { continue; }
497
+ if (placedObjs.has(Number(oid))) { continue; }
498
+ const col = seq % 6;
499
+ const row = Math.floor(seq / 6);
500
+ newObjs.push({
501
+ Diagram_ID: diagramId, Object_ID: Number(oid),
502
+ RectLeft: 40 + col * 260, RectTop: 40 + row * 160,
503
+ RectRight: 40 + col * 260 + 180, RectBottom: 40 + row * 160 + 90,
504
+ Sequence: seq,
505
+ });
506
+ seq++;
507
+ }
508
+ if (newObjs.length > 0 && !o.dryRun) {
509
+ insertMany(db, 't_diagramobjects', ['Diagram_ID', 'Object_ID', 'RectLeft', 'RectTop', 'RectRight', 'RectBottom', 'Sequence'], newObjs);
510
+ }
511
+ stats.added.diagramObjects += newObjs.length;
512
+
513
+ const placedLinks = new Set();
514
+ const links = db.prepare('SELECT ConnectorID FROM t_diagramlinks WHERE DiagramID=?').all(diagramId);
515
+ for (const r of links) { placedLinks.add(Number(r.ConnectorID)); }
516
+ const newLinks = [];
517
+ for (const relId of view.included_relationships || []) {
518
+ const cid = relAliasToId.get(String(relId));
519
+ if (cid === undefined || cid < 0) { continue; }
520
+ if (placedLinks.has(Number(cid))) { continue; }
521
+ newLinks.push({ DiagramID: diagramId, ConnectorID: Number(cid), Style: '', Geometry: '' });
522
+ }
523
+ if (newLinks.length > 0 && !o.dryRun) {
524
+ insertMany(db, 't_diagramlinks', ['DiagramID', 'ConnectorID', 'Style', 'Geometry'], newLinks);
525
+ }
526
+ stats.added.diagramLinks += newLinks.length;
527
+ }
528
+ stages.members = nowMs();
529
+
530
+ // --- deletion reconcile (opt-in) ---------------------------------------
531
+ const keepAliases = new Set();
532
+ for (const e of graph.elements || []) { if (e && e.id !== undefined) { keepAliases.add(String(e.id)); } }
533
+ for (const rel of graph.relationships || []) { if (rel && rel.id !== undefined) { keepAliases.add(String(rel.id)); } }
534
+ const candidates = [];
535
+ for (const row of existingElems) {
536
+ if (row.Alias && !keepAliases.has(String(row.Alias))) { candidates.push({ type: 'element', id: Number(row.Object_ID), alias: row.Alias }); }
537
+ }
538
+ for (const row of existingRels) {
539
+ if (row.Alias && !keepAliases.has(String(row.Alias))) { candidates.push({ type: 'relationship', id: Number(row.Connector_ID), alias: row.Alias }); }
540
+ }
541
+ stats.deleteCandidates = candidates.length;
542
+ if (candidates.length > 0 && o.allowDelete && !o.dryRun) {
543
+ for (const c of candidates) {
544
+ if (c.type === 'relationship') {
545
+ db.prepare('DELETE FROM t_connector WHERE Connector_ID=?').run(c.id);
546
+ } else {
547
+ db.prepare('DELETE FROM t_diagramobjects WHERE Object_ID=?').run(c.id);
548
+ db.prepare('DELETE FROM t_objectproperties WHERE Object_ID=?').run(c.id);
549
+ db.prepare('DELETE FROM t_object WHERE Object_ID=?').run(c.id);
550
+ }
551
+ stats.deleted++;
552
+ }
553
+ }
554
+ stages.deletes = nowMs();
555
+ if (!o.dryRun) { db.exec('COMMIT'); }
556
+ } finally {
557
+ try { db.close(); } catch { /* ignore */ }
558
+ }
559
+ stats.ms = {
560
+ total: nowMs() - t0,
561
+ package: stages.package - t0,
562
+ elements: stages.elements - stages.package,
563
+ elemTags: stages.elemTags - stages.elements,
564
+ relationships: stages.relationships - stages.elemTags,
565
+ relTags: stages.relTags - stages.relationships,
566
+ views: stages.members - stages.relTags,
567
+ members: stages.members - stages.relTags,
568
+ };
569
+ return { ok: true, syncPackageId: stats.syncPackageId, stats };
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // ---------------------------------------------------------------------------
574
+ // Full projection (whole-file rebuild, decision qea-full-wholefile-argo-scripts-no-config):
575
+ // wipe ALL existing content in the target .qea (every user table row, incl. kg_sync_meta),
576
+ // re-seed the minimal root package, then rebuild the whole .qea purely from the canonical
577
+ // graph. EA is treated as a projection: after full there is nothing but canonical content.
578
+ // ---------------------------------------------------------------------------
579
+ function wipeAllContent(db) {
580
+ const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
581
+ const cleared = {};
582
+ for (const r of rows) {
583
+ try {
584
+ const before = db.prepare('SELECT COUNT(*) AS c FROM "' + r.name + '"').get().c;
585
+ db.exec('DELETE FROM "' + r.name + '"');
586
+ cleared[r.name] = before;
587
+ } catch { /* skip locked/system tables */ }
588
+ }
589
+ try { db.exec('DELETE FROM sqlite_sequence'); } catch { /* ignore */ }
590
+ return cleared;
591
+ }
592
+ function ensureRootPackage(db) {
593
+ const roots = db.prepare('SELECT Package_ID FROM t_package WHERE Parent_ID=0 ORDER BY Package_ID').all();
594
+ if (roots.length > 0) { return Number(roots[0].Package_ID); }
595
+ const r = db.prepare('INSERT INTO t_package (Name, Parent_ID, ea_guid) VALUES (?,?,?)').run('Model', 0, deterministicGuid('pkg:Model'));
596
+ return Number(r.lastInsertRowid);
597
+ }
598
+ function verifyQeaCanonical(graph, qeaPath) {
599
+ let roundtrip = null;
600
+ try { roundtrip = require('../../tests/_ea-roundtrip-lib.js'); } catch { roundtrip = null; }
601
+ const exp = exportQeaToGraph(qeaPath);
602
+ const counts = {
603
+ elements: exp.elements.length, relationships: exp.relationships.length, views: exp.views.length,
604
+ sourceElements: (graph.elements || []).length,
605
+ sourceRelationships: (graph.relationships || []).length,
606
+ sourceViews: (graph.views || []).length,
607
+ };
608
+ let equal = counts.elements === counts.sourceElements && counts.relationships === counts.sourceRelationships && counts.views === counts.sourceViews;
609
+ let diffs = null;
610
+ if (roundtrip && typeof roundtrip.compareRoundtrip === 'function') {
611
+ const rep = roundtrip.compareRoundtrip(graph, exp, {});
612
+ equal = rep.equal;
613
+ diffs = { missing: rep.missingInExport.length, extra: rep.extraInExport.length, valueDiffs: rep.valueDiffs.length };
614
+ }
615
+ return { consistent: equal, diffs, counts };
616
+ }
617
+ function fullProjection(graph, qeaPath, opts) {
618
+ const o = opts || {};
619
+ const t0 = nowMs();
620
+ let wiped = null;
621
+ let db = null;
622
+ try {
623
+ db = openQea(qeaPath);
624
+ ensureMetaTable(db);
625
+ db.exec('BEGIN IMMEDIATE');
626
+ wiped = wipeAllContent(db);
627
+ ensureRootPackage(db);
628
+ db.exec('COMMIT');
629
+ } finally {
630
+ if (db) { try { db.close(); } catch { /* ignore */ } }
631
+ }
632
+ const sync = syncGraphToQea(graph, qeaPath, { dryRun: o.dryRun, allowDelete: false });
633
+ const verification = o.verify === false ? null : verifyQeaCanonical(graph, qeaPath);
634
+ return { ok: true, wiped, sync: sync.stats, verification, ms: { total: nowMs() - t0 } };
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Export
639
+ // ---------------------------------------------------------------------------
640
+ // ---------------------------------------------------------------------------
641
+ // Export
642
+ // ---------------------------------------------------------------------------
643
+ function exportQeaToGraph(qeaPath) {
644
+ const db = openQea(qeaPath);
645
+ try {
646
+ ensureMetaTable(db);
647
+ const elements = readMetaKind(db, 'element').filter((e) => e && e.id !== undefined);
648
+ const relationships = readMetaKind(db, 'relationship').filter((r) => r && r.id !== undefined);
649
+ const views = readMetaKind(db, 'view').filter((v) => v && v.view_id !== undefined);
650
+ return { name: 'ArchGraph (from archgraph.qea)', description: '', elements, relationships, views };
651
+ } finally {
652
+ db.close();
653
+ }
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // snapshot
658
+ // ---------------------------------------------------------------------------
659
+ function snapshotQea(qeaPath, snapshotDir) {
660
+ const fs = require('node:fs');
661
+ const path = require('node:path');
662
+ if (!fs.existsSync(qeaPath)) { return null; }
663
+ const dir = snapshotDir || path.dirname(qeaPath);
664
+ fs.mkdirSync(dir, { recursive: true });
665
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
666
+ const target = path.join(dir, path.basename(qeaPath).replace(/(\.qea)$/i, '_before_sync_' + ts + '$1'));
667
+ fs.copyFileSync(qeaPath, target);
668
+ return target;
669
+ }
670
+
671
+ module.exports = {
672
+ SYNC_PACKAGE_NAME,
673
+ META_TABLE,
674
+ openQea,
675
+ deterministicGuid,
676
+ elementObjectType,
677
+ elementStereotype,
678
+ relationshipMap,
679
+ canonicalArchimateType,
680
+ syncGraphToQea,
681
+ exportQeaToGraph,
682
+ snapshotQea,
683
+ readMetaKind,
684
+ fullProjection,
685
+ wipeAllContent,
686
+ ensureRootPackage,
687
+ verifyQeaCanonical,
688
+ };
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // WP2791 Node direct .qea projection CLI (no EA, no third-party deps).
5
+ // node argo/scripts/ea-qea-sync.js --graph <json> --qea <file.qea> --mode import|sync|full|export|watch
6
+ // [--delete-confirm-file <f> | -y] [--dry-run] [--snapshot-dir <dir>] [--out <file>] [--no-backup]
7
+ //
8
+ // import/sync : project design/KG graph into the .qea (update-in-place, batch INSERT,
9
+ // full : clear projection-owned content (kg_sync_meta + ArchGraph Sync subtree) then
10
+ // unchanged fingerprint skip, opt-in EA-only delete with confirmation).
11
+ // export : read .qea back into graph-shaped JSON (roundtrip comparable).
12
+ // watch : on graph JSON change, run sync (fs.watch + polling fallback).
13
+ // Every write first snapshots the target as <file>_before_sync_<ts> unless --no-backup.
14
+
15
+ const path = require('node:path');
16
+ const fs = require('node:fs');
17
+ const lib = require(path.join(__dirname, 'ea-qea-sync-lib.js'));
18
+
19
+ function parseArgs(argv) {
20
+ const args = { mode: 'sync', graph: '', qea: '', allowDelete: false, deleteConfirmFile: '', dryRun: false, snapshotDir: '', out: '', noBackup: false, intervalMs: 2000 };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ const next = () => (i + 1 < argv.length ? argv[++i] : '');
24
+ if (a === '--graph') { args.graph = next(); }
25
+ else if (a === '--qea') { args.qea = next(); }
26
+ else if (a === '--mode') { args.mode = next(); }
27
+ else if (a === '--delete-confirm-file') { args.deleteConfirmFile = next(); args.allowDelete = true; }
28
+ else if (a === '-y' || a === '--yes') { args.allowDelete = true; }
29
+ else if (a === '--dry-run') { args.dryRun = true; }
30
+ else if (a === '--snapshot-dir') { args.snapshotDir = next(); }
31
+ else if (a === '--out') { args.out = next(); }
32
+ else if (a === '--no-backup') { args.noBackup = true; }
33
+ else if (a === '--interval') { args.intervalMs = Number(next()) || 2000; }
34
+ else if (a.startsWith('-')) { /* ignore unknown */ }
35
+ else if (args.modeSet === undefined) { /* positional not used */ }
36
+ }
37
+ return args;
38
+ }
39
+
40
+ function readGraph(jsonPath) {
41
+ const raw = fs.readFileSync(jsonPath, 'utf8').replace(/^\uFEFF/, '');
42
+ return JSON.parse(raw);
43
+ }
44
+ function confirmDelete(args) {
45
+ if (args.allowDelete) { return true; }
46
+ if (args.deleteConfirmFile) {
47
+ try {
48
+ const text = fs.readFileSync(args.deleteConfirmFile, 'utf8').trim().toLowerCase();
49
+ return text.indexOf('delete') >= 0;
50
+ } catch { return false; }
51
+ }
52
+ return false;
53
+ }
54
+
55
+ async function main() {
56
+ const args = parseArgs(process.argv.slice(2));
57
+ const cwd = process.cwd();
58
+ const graphPath = path.resolve(cwd, args.graph || 'design/KG/SystemArchitecture.json');
59
+ const qeaPath = path.resolve(cwd, args.qea || 'archgraph.qea');
60
+ if (!fs.existsSync(qeaPath)) {
61
+ console.error('qea not found: ' + qeaPath);
62
+ process.exit(2);
63
+ }
64
+ if (args.mode === 'export') {
65
+ const graph = lib.exportQeaToGraph(qeaPath);
66
+ const text = JSON.stringify(graph, null, 2);
67
+ if (args.out) { fs.writeFileSync(path.resolve(cwd, args.out), text, 'utf8'); console.log('export written to ' + args.out); }
68
+ else { console.log(text); }
69
+ return;
70
+ }
71
+ if (!fs.existsSync(graphPath)) {
72
+ console.error('graph not found: ' + graphPath);
73
+ process.exit(2);
74
+ }
75
+ const graph = readGraph(graphPath);
76
+ if (args.mode === 'watch') {
77
+ // eslint-disable-next-line no-constant-condition
78
+ while (true) {
79
+ runOnce(args, graphPath, qeaPath);
80
+ const m0 = statHash(graphPath);
81
+ await sleep(args.intervalMs);
82
+ const m1 = statHash(graphPath);
83
+ if (m0 !== m1) { continue; } // changed while waiting -> immediate re-sync
84
+ }
85
+ } else {
86
+ runOnce(args, graphPath, qeaPath);
87
+ }
88
+ }
89
+ function statHash(p) {
90
+ try { const st = fs.statSync(p); return st.size + ':' + st.mtimeMs; } catch { return 'gone'; }
91
+ }
92
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
93
+ function runOnce(args, graphPath, qeaPath) {
94
+ const graph = readGraph(graphPath);
95
+ let snapshot = null;
96
+ if (!args.dryRun && !args.noBackup) {
97
+ snapshot = lib.snapshotQea(qeaPath, args.snapshotDir || undefined);
98
+ }
99
+ const res = args.mode === 'full'
100
+ ? lib.fullProjection(graph, qeaPath, { dryRun: args.dryRun })
101
+ : lib.syncGraphToQea(graph, qeaPath, { dryRun: args.dryRun, allowDelete: confirmDelete(args) });
102
+ const mode = args.dryRun ? 'dry-run' : 'sync';
103
+ console.log(JSON.stringify({ mode, graph: graphPath, qea: qeaPath, snapshot, result: res }, null, 2));
104
+ }
105
+
106
+ main().catch((e) => {
107
+ console.error('ea-qea-sync failed: ' + (e && e.message));
108
+ process.exit(1);
109
+ });
@@ -1,5 +1,6 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
+ const { spawn } = require('node:child_process');
3
4
  const readline = require('node:readline');
4
5
  const crypto = require('node:crypto');
5
6
 
@@ -1615,6 +1616,25 @@ async function buildMutationResult(context, mutations, write) {
1615
1616
  writeGraph(context.graphPath.absolutePath, mutationResult.document);
1616
1617
  result.written = true;
1617
1618
 
1619
+ // WP2791: .qea projection parallel to the Neo4j trigger — non-fatal, best-effort.
1620
+ {
1621
+ const qeaTarget = resolveQeaProjectionTarget(context);
1622
+ if (qeaTarget) {
1623
+ try {
1624
+ const projection = await runQeaProjection(qeaTarget);
1625
+ if (projection.ok) {
1626
+ result.qeaProjection = { status: 'passed', qea: qeaTarget.qeaPath, ms: projection.ms };
1627
+ } else {
1628
+ result.qeaProjection = { status: 'failed', qea: qeaTarget.qeaPath, error: projection.error || ('exit ' + projection.code), ms: projection.ms };
1629
+ result.warnings = addUnique(result.warnings || [], ['ea-qea projection failed (non-fatal): ' + (projection.error || ('exit code ' + projection.code))]);
1630
+ }
1631
+ } catch (error) {
1632
+ result.qeaProjection = { status: 'failed', error: String(error && error.message ? error.message : error) };
1633
+ result.warnings = addUnique(result.warnings || [], ['ea-qea projection error (non-fatal): ' + String(error && error.message ? error.message : error)]);
1634
+ }
1635
+ }
1636
+ }
1637
+
1618
1638
  if (shouldSyncCanonicalGraphToNeo4j(context.graphPath.relativePath)) {
1619
1639
  try {
1620
1640
  const syncResult = await syncArchitectureToNeo4j({
@@ -1812,6 +1832,59 @@ function summarizeDocument(document) {
1812
1832
  };
1813
1833
  }
1814
1834
 
1835
+ // --- WP2791: post-canonical-write .qea projection (parallel to Neo4j sync, non-fatal) ---
1836
+ // Target resolution (decision qea-full-wholefile-argo-scripts-no-config): env ARGO_EA_QEA >
1837
+ // the single *.qea at the workspace root (0/many -> no-op with an explicit log). NO config file.
1838
+ // Projection script runs from argo/scripts (same package as the MCP runtime), so a workspace
1839
+ // 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
+ }
1860
+ }
1861
+
1862
+ function runQeaProjection(target) {
1863
+ return new Promise((resolve) => {
1864
+ const script = path.join(__dirname, 'ea-qea-sync.js');
1865
+ if (!fs.existsSync(script)) {
1866
+ resolve({ ok: false, error: 'argo/scripts/ea-qea-sync.js missing', ms: 0 });
1867
+ return;
1868
+ }
1869
+ const snapshotDir = path.join(target.workspaceRoot, '.argo', 'temp', 'qea-backups');
1870
+ const args = [script, '--mode', 'sync', '--graph', target.graphPath, '--qea', target.qeaPath, '--snapshot-dir', snapshotDir];
1871
+ const started = Date.now();
1872
+ let stderr = '';
1873
+ let child;
1874
+ try {
1875
+ child = spawn(process.execPath, args, { cwd: target.workspaceRoot, windowsHide: true });
1876
+ } catch (error) {
1877
+ resolve({ ok: false, error: String(error && error.message ? error.message : error), ms: Date.now() - started });
1878
+ return;
1879
+ }
1880
+ child.stderr.on('data', (d) => { stderr += String(d); });
1881
+ child.on('error', (err) => resolve({ ok: false, error: String(err && err.message ? err.message : err), ms: Date.now() - started, stderr: stderr.slice(0, 600) }));
1882
+ child.on('close', (code) => {
1883
+ resolve({ ok: code === 0, code, ms: Date.now() - started, stderr: stderr.slice(0, 600) });
1884
+ });
1885
+ });
1886
+ }
1887
+
1815
1888
  function writeGraph(graphPath, document) {
1816
1889
  const tempPath = `${graphPath}.${process.pid}.${Date.now()}.tmp`;
1817
1890
  fs.writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.10.52",
3
+ "version": "0.12.0",
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": {
@@ -38,7 +38,8 @@
38
38
  "neo4j-driver": "^6.2.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@resvg/resvg-js": "^2.6.2"
41
+ "@resvg/resvg-js": "^2.6.2",
42
+ "playwright": "1.61.1"
42
43
  },
43
44
  "scripts": {
44
45
  "test": "node --test \"tests/*.test.js\""