nexarch 0.9.4 → 0.9.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.
@@ -1,4 +1,5 @@
1
1
  import process from "process";
2
+ import { existsSync, readFileSync } from "node:fs";
2
3
  import { requireCredentials } from "../lib/credentials.js";
3
4
  import { callMcpTool } from "../lib/mcp.js";
4
5
  function parseFlag(args, flag) {
@@ -17,11 +18,65 @@ function parseToolText(result) {
17
18
  const text = result.content?.[0]?.text ?? "{}";
18
19
  return JSON.parse(text);
19
20
  }
21
+ function parseRelationships(args) {
22
+ const relationshipsFile = parseOptionValue(args, "--relationships-file");
23
+ const relationshipsJson = parseOptionValue(args, "--relationships-json");
24
+ if (relationshipsFile && relationshipsJson) {
25
+ throw new Error("Use only one of --relationships-file or --relationships-json");
26
+ }
27
+ if (relationshipsFile || relationshipsJson) {
28
+ let raw = relationshipsJson;
29
+ if (relationshipsFile) {
30
+ if (!existsSync(relationshipsFile))
31
+ throw new Error(`--relationships-file not found: ${relationshipsFile}`);
32
+ raw = readFileSync(relationshipsFile, "utf8");
33
+ }
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw ?? "[]");
37
+ }
38
+ catch {
39
+ throw new Error(relationshipsFile ? "--relationships-file must contain valid JSON array" : "--relationships-json must be valid JSON array");
40
+ }
41
+ if (!Array.isArray(parsed) || parsed.length === 0) {
42
+ throw new Error("Relationships input must be a non-empty JSON array");
43
+ }
44
+ return parsed.map((item, index) => {
45
+ const v = (item ?? {});
46
+ const from = String(v.fromEntityExternalKey ?? v.fromEntityRef ?? v.from ?? "").trim();
47
+ const to = String(v.toEntityExternalKey ?? v.toEntityRef ?? v.to ?? "").trim();
48
+ const type = String(v.relationshipTypeCode ?? v.type ?? "").trim();
49
+ const confidence = Number(v.confidence ?? 1);
50
+ if (!from || !to || !type) {
51
+ throw new Error(`Relationship #${index + 1} is missing required fields (from/to/type)`);
52
+ }
53
+ return {
54
+ fromEntityExternalKey: from,
55
+ toEntityExternalKey: to,
56
+ relationshipTypeCode: type,
57
+ confidence: Number.isFinite(confidence) ? confidence : 1,
58
+ };
59
+ });
60
+ }
61
+ const fromKey = parseOptionValue(args, "--from");
62
+ const toKey = parseOptionValue(args, "--to");
63
+ const relType = parseOptionValue(args, "--type");
64
+ if (!fromKey)
65
+ throw new Error("--from <externalKey> is required");
66
+ if (!toKey)
67
+ throw new Error("--to <externalKey> is required");
68
+ if (!relType)
69
+ throw new Error("--type <relationshipTypeCode> is required");
70
+ return [{
71
+ fromEntityExternalKey: fromKey,
72
+ toEntityExternalKey: toKey,
73
+ relationshipTypeCode: relType,
74
+ confidence: 1,
75
+ }];
76
+ }
20
77
  async function tryCreateStubEntity(externalKey, mcpOpts, agentContext, policyContext) {
21
- // Only auto-create global reference entities — company entities must be created explicitly
22
78
  if (!externalKey.startsWith("global:"))
23
79
  return false;
24
- // Derive a normalised alias from the external key (e.g. "global:platform:vercel" → "vercel")
25
80
  const parts = externalKey.split(":");
26
81
  const alias = parts[parts.length - 1];
27
82
  const resolveRaw = await callMcpTool("nexarch_resolve_reference", { names: [alias], companyId: mcpOpts.companyId }, mcpOpts);
@@ -42,21 +97,7 @@ async function tryCreateStubEntity(externalKey, mcpOpts, agentContext, policyCon
42
97
  }
43
98
  export async function addRelationship(args) {
44
99
  const asJson = parseFlag(args, "--json");
45
- const fromKey = parseOptionValue(args, "--from");
46
- const toKey = parseOptionValue(args, "--to");
47
- const relType = parseOptionValue(args, "--type");
48
- if (!fromKey) {
49
- console.error("error: --from <externalKey> is required");
50
- process.exit(1);
51
- }
52
- if (!toKey) {
53
- console.error("error: --to <externalKey> is required");
54
- process.exit(1);
55
- }
56
- if (!relType) {
57
- console.error("error: --type <relationshipTypeCode> is required");
58
- process.exit(1);
59
- }
100
+ const relationships = parseRelationships(args);
60
101
  const creds = requireCredentials();
61
102
  const mcpOpts = { companyId: creds.companyId };
62
103
  const policiesRaw = await callMcpTool("nexarch_get_applied_policies", {}, mcpOpts);
@@ -66,7 +107,7 @@ export async function addRelationship(args) {
66
107
  const agentContext = {
67
108
  agentId: "nexarch-cli:add-relationship",
68
109
  agentRunId: `add-relationship-${Date.now()}`,
69
- repoRef: fromKey,
110
+ repoRef: relationships[0]?.fromEntityExternalKey ?? "batch",
70
111
  observedAt: nowIso,
71
112
  source: "nexarch-cli",
72
113
  model: "n/a",
@@ -75,27 +116,18 @@ export async function addRelationship(args) {
75
116
  const policyContext = policyBundleHash
76
117
  ? { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } }
77
118
  : undefined;
78
- const relationship = {
79
- relationshipTypeCode: relType,
80
- fromEntityExternalKey: fromKey,
81
- toEntityExternalKey: toKey,
82
- confidence: 1,
83
- };
84
- let raw = await callMcpTool("nexarch_upsert_relationships", { relationships: [relationship], agentContext, policyContext }, mcpOpts);
119
+ let raw = await callMcpTool("nexarch_upsert_relationships", { relationships, agentContext, policyContext }, mcpOpts);
85
120
  let result = parseToolText(raw);
86
- // Auto-create global reference stubs if endpoints are missing, then retry once
87
121
  const missingEndpoint = result.errors?.some((e) => e.error === "RELATIONSHIP_ENDPOINT_NOT_FOUND");
88
122
  if (missingEndpoint) {
89
- const failedFromKeys = result.errors
90
- ?.filter((e) => e.error === "RELATIONSHIP_ENDPOINT_NOT_FOUND" && e.fromEntityExternalKey)
91
- .map((e) => e.fromEntityExternalKey);
92
- const failedToKeys = result.errors
93
- ?.filter((e) => e.error === "RELATIONSHIP_ENDPOINT_NOT_FOUND" && e.toEntityExternalKey)
94
- .map((e) => e.toEntityExternalKey);
95
- const keysToCreate = [...new Set([...(failedFromKeys ?? []), ...(failedToKeys ?? [])])];
96
- // If no specific endpoint keys returned, fall back to trying both
97
- if (keysToCreate.length === 0) {
98
- keysToCreate.push(fromKey, toKey);
123
+ const keysToCreate = new Set();
124
+ for (const err of result.errors ?? []) {
125
+ if (err.error !== "RELATIONSHIP_ENDPOINT_NOT_FOUND")
126
+ continue;
127
+ if (err.fromEntityExternalKey)
128
+ keysToCreate.add(err.fromEntityExternalKey);
129
+ if (err.toEntityExternalKey)
130
+ keysToCreate.add(err.toEntityExternalKey);
99
131
  }
100
132
  let createdAny = false;
101
133
  for (const key of keysToCreate) {
@@ -104,7 +136,7 @@ export async function addRelationship(args) {
104
136
  createdAny = true;
105
137
  }
106
138
  if (createdAny) {
107
- raw = await callMcpTool("nexarch_upsert_relationships", { relationships: [relationship], agentContext, policyContext }, mcpOpts);
139
+ raw = await callMcpTool("nexarch_upsert_relationships", { relationships, agentContext, policyContext }, mcpOpts);
108
140
  result = parseToolText(raw);
109
141
  }
110
142
  }
@@ -117,12 +149,12 @@ export async function addRelationship(args) {
117
149
  const succeeded = result.summary?.succeeded ?? 0;
118
150
  const failed = result.summary?.failed ?? 0;
119
151
  if (failed > 0) {
120
- console.error(`Failed to add relationship: ${fromKey} -[${relType}]-> ${toKey}`);
152
+ console.error(`Failed to add relationships (${failed}/${relationships.length}).`);
121
153
  const missingFrom = new Set();
122
154
  const missingTo = new Set();
123
155
  for (const err of result.errors ?? []) {
124
- const fromEndpoint = err.fromEntityExternalKey ?? fromKey;
125
- const toEndpoint = err.toEntityExternalKey ?? toKey;
156
+ const fromEndpoint = err.fromEntityExternalKey ?? "(unknown-from)";
157
+ const toEndpoint = err.toEntityExternalKey ?? "(unknown-to)";
126
158
  const message = err.message ?? "(no details returned)";
127
159
  if (err.error === "RELATIONSHIP_ENDPOINT_NOT_FOUND") {
128
160
  if (err.fromEntityExternalKey)
@@ -146,5 +178,11 @@ export async function addRelationship(args) {
146
178
  process.exitCode = 1;
147
179
  return;
148
180
  }
149
- console.log(`Added ${succeeded} relationship: ${fromKey} -[${relType}]-> ${toKey}`);
181
+ if (relationships.length === 1) {
182
+ const r = relationships[0];
183
+ console.log(`Added ${succeeded} relationship: ${r.fromEntityExternalKey} -[${r.relationshipTypeCode}]-> ${r.toEntityExternalKey}`);
184
+ }
185
+ else {
186
+ console.log(`Added ${succeeded}/${relationships.length} relationships.`);
187
+ }
150
188
  }
@@ -453,7 +453,29 @@ function classifySubPackage(pkgPath, relativePath) {
453
453
  return { entityType: "technology_component", subtype: "tech_library" };
454
454
  return { entityType: "technology_component", subtype: "tech_library" };
455
455
  }
456
- // apps/* or src/* → deployable application; check scripts
456
+ // apps/* → deployable application component under the root application.
457
+ if (topDir === "apps") {
458
+ const name = relativePath.toLowerCase();
459
+ if (name.includes("worker"))
460
+ return { entityType: "application_component", subtype: "app_comp_worker" };
461
+ if (name.includes("job"))
462
+ return { entityType: "application_component", subtype: "app_comp_job" };
463
+ if (name.includes("etl"))
464
+ return { entityType: "application_component", subtype: "app_comp_etl" };
465
+ if (name.includes("adapter"))
466
+ return { entityType: "application_component", subtype: "app_comp_adapter" };
467
+ if (name.includes("batch"))
468
+ return { entityType: "application_component", subtype: "app_comp_batch" };
469
+ if (name.includes("pipeline"))
470
+ return { entityType: "application_component", subtype: "app_comp_data_pipeline" };
471
+ if (name.includes("ui") || name.includes("web") || name.includes("front") || name.includes("site") || name.includes("portal") || name.includes("admin")) {
472
+ return { entityType: "application_component", subtype: "app_comp_ui" };
473
+ }
474
+ if (name.includes("api"))
475
+ return { entityType: "application_component", subtype: "app_comp_api" };
476
+ return { entityType: "application_component", subtype: "app_comp_api" };
477
+ }
478
+ // src/* → deployable application; check scripts
457
479
  try {
458
480
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
459
481
  const scripts = Object.keys(pkg.scripts ?? {});
@@ -758,6 +780,9 @@ function scanEcosystems(dir) {
758
780
  }
759
781
  return { names, ecosystems };
760
782
  }
783
+ function isApplicationLikeEntityType(entityType) {
784
+ return entityType === "application" || entityType === "application_component";
785
+ }
761
786
  function scanProject(dir) {
762
787
  const names = new Set();
763
788
  let projectName = basename(dir);
@@ -964,12 +989,34 @@ async function promptApplicationChoice(matches, allApps, suggested) {
964
989
  export async function initProject(args) {
965
990
  const asJson = parseFlag(args, "--json");
966
991
  const dryRun = parseFlag(args, "--dry-run");
992
+ const profileEnabled = parseFlag(args, "--profile");
967
993
  const commandStartedAt = Date.now();
994
+ const profile = {
995
+ enabled: profileEnabled,
996
+ startedAt: new Date(commandStartedAt).toISOString(),
997
+ events: [],
998
+ mcpCalls: [],
999
+ upsertBatches: {
1000
+ entities: [],
1001
+ relationships: [],
1002
+ },
1003
+ };
968
1004
  const logProgress = (phase, details) => {
969
- const elapsed = formatMs(Date.now() - commandStartedAt);
1005
+ const atMs = Date.now() - commandStartedAt;
1006
+ if (profileEnabled)
1007
+ profile.events.push({ phase, atMs, details });
1008
+ const elapsed = formatMs(atMs);
970
1009
  const line = details ? `[init-project +${elapsed}] ${phase} — ${details}` : `[init-project +${elapsed}] ${phase}`;
971
1010
  process.stderr.write(`${line}\n`);
972
1011
  };
1012
+ const callMcpProfiled = async (toolName, input, meta) => {
1013
+ const startedAt = Date.now();
1014
+ const result = await callMcpTool(toolName, input, mcpOpts);
1015
+ if (profileEnabled) {
1016
+ profile.mcpCalls.push({ tool: toolName, durationMs: Date.now() - startedAt, meta });
1017
+ }
1018
+ return result;
1019
+ };
973
1020
  const dirArg = parseOptionValue(args, "--dir") ?? process.cwd();
974
1021
  const dir = resolvePath(dirArg);
975
1022
  const nameOverride = parseOptionValue(args, "--name");
@@ -1030,7 +1077,7 @@ export async function initProject(args) {
1030
1077
  for (let i = 0; i < detectedNames.length; i += BATCH_SIZE) {
1031
1078
  const batch = detectedNames.slice(i, i + BATCH_SIZE);
1032
1079
  logProgress("resolve.batch", `${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(detectedNames.length / BATCH_SIZE)} size=${batch.length}`);
1033
- const raw = await callMcpTool("nexarch_resolve_reference", { names: batch, companyId: creds.companyId }, mcpOpts);
1080
+ const raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId }, { batchSize: batch.length });
1034
1081
  const data = parseToolText(raw);
1035
1082
  allResolveResults.push(...data.results);
1036
1083
  }
@@ -1070,7 +1117,7 @@ export async function initProject(args) {
1070
1117
  return;
1071
1118
  }
1072
1119
  logProgress("preflight.onboarding.check");
1073
- const onboardingRaw = await callMcpTool("nexarch_get_company_onboarding", {}, mcpOpts);
1120
+ const onboardingRaw = await callMcpProfiled("nexarch_get_company_onboarding", {});
1074
1121
  const onboarding = parseToolText(onboardingRaw);
1075
1122
  if (onboarding.isComplete !== true) {
1076
1123
  const message = "Company onboarding is incomplete. Complete onboarding before running init-project.";
@@ -1083,7 +1130,7 @@ export async function initProject(args) {
1083
1130
  }
1084
1131
  // Policy bootstrap
1085
1132
  logProgress("preflight.policies.check");
1086
- const policiesRaw = await callMcpTool("nexarch_get_applied_policies", {}, mcpOpts);
1133
+ const policiesRaw = await callMcpProfiled("nexarch_get_applied_policies", {});
1087
1134
  const policies = parseToolText(policiesRaw);
1088
1135
  const policyBundleHash = policies.policyBundleHash ?? null;
1089
1136
  if (!policyBundleHash) {
@@ -1108,7 +1155,7 @@ export async function initProject(args) {
1108
1155
  console.log(`\nUsing --application-ref target: ${projectExternalKey}`);
1109
1156
  }
1110
1157
  else {
1111
- const appsRaw = await callMcpTool("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, mcpOpts);
1158
+ const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500 });
1112
1159
  const appsData = parseToolText(appsRaw);
1113
1160
  const apps = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
1114
1161
  if (apps.length > 0) {
@@ -1125,18 +1172,40 @@ export async function initProject(args) {
1125
1172
  console.log(`\nAuto-mapped to existing application: ${suggested.name} (${projectExternalKey})`);
1126
1173
  }
1127
1174
  else if (!interactiveAllowed) {
1128
- if (highConfidence) {
1129
- projectExternalKey = suggested.entityRef;
1175
+ const message = "Application mapping requires explicit choice in non-interactive mode. Pass --application-ref <entityRef> or --create-application (or run interactively).";
1176
+ const existingApplications = apps.slice(0, 25).map((a) => ({
1177
+ entityRef: a.entityRef ?? a.externalKey,
1178
+ name: a.name,
1179
+ entityTypeCode: a.entityTypeCode,
1180
+ }));
1181
+ if (asJson) {
1182
+ process.stdout.write(`${JSON.stringify({
1183
+ ok: true,
1184
+ status: "input_required",
1185
+ code: "APPLICATION_MAPPING_REQUIRED",
1186
+ message,
1187
+ suggested: suggested ?? null,
1188
+ candidates: matches.slice(0, 10),
1189
+ existingApplications,
1190
+ requiredInput: {
1191
+ applicationRefOption: "--application-ref <entityRef>",
1192
+ createOption: "--create-application",
1193
+ },
1194
+ }, null, 2)}\n`);
1195
+ return;
1130
1196
  }
1131
- else if (autoMapApplication) {
1132
- const message = "Ambiguous application mapping in non-interactive mode. Pass --application-ref or --create-application.";
1133
- if (asJson) {
1134
- process.stdout.write(`${JSON.stringify({ ok: false, error: "APPLICATION_MAPPING_AMBIGUOUS", message, candidates: matches.slice(0, 5) }, null, 2)}\n`);
1135
- process.exitCode = 1;
1136
- return;
1197
+ console.log("\nInput required before continuing:");
1198
+ console.log(` ${message}`);
1199
+ if (suggested) {
1200
+ console.log(` Suggested: ${suggested.name} (${suggested.entityRef}) score=${suggested.score.toFixed(2)}`);
1201
+ }
1202
+ if (existingApplications.length > 0) {
1203
+ console.log(" Existing applications:");
1204
+ for (const app of existingApplications) {
1205
+ console.log(` - ${app.name} (${app.entityRef})`);
1137
1206
  }
1138
- throw new Error(message);
1139
1207
  }
1208
+ return;
1140
1209
  }
1141
1210
  else {
1142
1211
  const chosen = await promptApplicationChoice(matches, apps, highConfidence ? suggested : null);
@@ -1255,10 +1324,15 @@ export async function initProject(args) {
1255
1324
  // - Sub-packages that are apps → part_of the top-level project
1256
1325
  const relationships = [];
1257
1326
  const seenRelPairs = new Set();
1327
+ let relationshipAddAttempts = 0;
1328
+ let relationshipSkippedAsDuplicate = 0;
1258
1329
  function addRel(type, from, to, confidence = 0.9, attributes) {
1330
+ relationshipAddAttempts += 1;
1259
1331
  const key = `${type}::${from}::${to}`;
1260
- if (seenRelPairs.has(key))
1332
+ if (seenRelPairs.has(key)) {
1333
+ relationshipSkippedAsDuplicate += 1;
1261
1334
  return;
1335
+ }
1262
1336
  seenRelPairs.add(key);
1263
1337
  relationships.push({ relationshipTypeCode: type, fromEntityExternalKey: from, toEntityExternalKey: to, confidence, attributes });
1264
1338
  }
@@ -1281,7 +1355,7 @@ export async function initProject(args) {
1281
1355
  continue;
1282
1356
  seenSubKeys.add(`rel:${sp.externalKey}`);
1283
1357
  // Apps are part_of the top-level project
1284
- if (sp.entityType === "application") {
1358
+ if (sp.entityType === "application_component") {
1285
1359
  addRel("part_of", sp.externalKey, projectExternalKey);
1286
1360
  }
1287
1361
  // Wire each sub-package's resolved deps to itself
@@ -1299,7 +1373,7 @@ export async function initProject(args) {
1299
1373
  // Company org is accountable_for the top-level project entity
1300
1374
  let orgExternalKey = `organisation:${normalizeToken(creds.companyId)}`;
1301
1375
  try {
1302
- const orgRaw = await callMcpTool("nexarch_list_entities", { entityTypeCode: "organisation", status: "active", limit: 1, companyId: creds.companyId }, mcpOpts);
1376
+ const orgRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "organisation", status: "active", limit: 1, companyId: creds.companyId }, { entityTypeCode: "organisation", limit: 1 });
1303
1377
  const orgData = parseToolText(orgRaw);
1304
1378
  const org = (orgData.entities ?? [])[0];
1305
1379
  if (org?.entityRef || org?.externalKey)
@@ -1311,7 +1385,7 @@ export async function initProject(args) {
1311
1385
  addRel("accountable_for", orgExternalKey, projectExternalKey, 1);
1312
1386
  // Also accountable_for any sub-package applications
1313
1387
  for (const sp of subPackages) {
1314
- if (sp.externalKey && sp.entityType === "application") {
1388
+ if (sp.externalKey && isApplicationLikeEntityType(sp.entityType)) {
1315
1389
  addRel("accountable_for", orgExternalKey, sp.externalKey, 1);
1316
1390
  }
1317
1391
  }
@@ -1331,8 +1405,18 @@ export async function initProject(args) {
1331
1405
  for (let i = 0; i < entityChunks.length; i += 1) {
1332
1406
  const chunk = entityChunks[i];
1333
1407
  logProgress("upsert.entities.batch.start", `${i + 1}/${entityChunks.length} size=${chunk.length}`);
1334
- const entitiesRaw = await callMcpTool("nexarch_upsert_entities", { entities: chunk, agentContext, policyContext }, mcpOpts);
1408
+ const entityBatchStartedAt = Date.now();
1409
+ const entitiesRaw = await callMcpProfiled("nexarch_upsert_entities", { entities: chunk, agentContext, policyContext }, { batchIndex: i + 1, totalBatches: entityChunks.length, batchSize: chunk.length });
1335
1410
  const chunkResult = parseToolText(entitiesRaw);
1411
+ if (profileEnabled) {
1412
+ profile.upsertBatches.entities.push({
1413
+ index: i + 1,
1414
+ size: chunk.length,
1415
+ durationMs: Date.now() - entityBatchStartedAt,
1416
+ succeeded: Number(chunkResult.summary?.succeeded ?? 0),
1417
+ failed: Number(chunkResult.summary?.failed ?? 0),
1418
+ });
1419
+ }
1336
1420
  entitiesResult.summary.requested = Number(entitiesResult.summary.requested ?? 0) + Number(chunkResult.summary?.requested ?? chunk.length);
1337
1421
  entitiesResult.summary.succeeded = Number(entitiesResult.summary.succeeded ?? 0) + Number(chunkResult.summary?.succeeded ?? 0);
1338
1422
  entitiesResult.summary.failed = Number(entitiesResult.summary.failed ?? 0) + Number(chunkResult.summary?.failed ?? 0);
@@ -1350,8 +1434,18 @@ export async function initProject(args) {
1350
1434
  for (let i = 0; i < relationshipChunks.length; i += 1) {
1351
1435
  const chunk = relationshipChunks[i];
1352
1436
  logProgress("upsert.relationships.batch.start", `${i + 1}/${relationshipChunks.length} size=${chunk.length}`);
1353
- const relsRaw = await callMcpTool("nexarch_upsert_relationships", { relationships: chunk, agentContext, policyContext }, mcpOpts);
1437
+ const relBatchStartedAt = Date.now();
1438
+ const relsRaw = await callMcpProfiled("nexarch_upsert_relationships", { relationships: chunk, agentContext, policyContext }, { batchIndex: i + 1, totalBatches: relationshipChunks.length, batchSize: chunk.length });
1354
1439
  const chunkResult = parseToolText(relsRaw);
1440
+ if (profileEnabled) {
1441
+ profile.upsertBatches.relationships.push({
1442
+ index: i + 1,
1443
+ size: chunk.length,
1444
+ durationMs: Date.now() - relBatchStartedAt,
1445
+ succeeded: Number(chunkResult.summary?.succeeded ?? 0),
1446
+ failed: Number(chunkResult.summary?.failed ?? 0),
1447
+ });
1448
+ }
1355
1449
  relsResult.summary.requested = Number(relsResult.summary.requested ?? 0) + Number(chunkResult.summary?.requested ?? chunk.length);
1356
1450
  relsResult.summary.succeeded = Number(relsResult.summary.succeeded ?? 0) + Number(chunkResult.summary?.succeeded ?? 0);
1357
1451
  relsResult.summary.failed = Number(relsResult.summary.failed ?? 0) + Number(chunkResult.summary?.failed ?? 0);
@@ -1364,6 +1458,7 @@ export async function initProject(args) {
1364
1458
  // Build structured enrichment task (included in JSON output and printed in human mode)
1365
1459
  const readmeHints = ["README.md", "README.mdx", "docs/README.md", "docs/index.md"]
1366
1460
  .filter((f) => existsSync(join(dir, f)));
1461
+ const preWiredRelationshipKeys = new Set(relationships.map((rel) => `${rel.relationshipTypeCode}::${rel.fromEntityExternalKey}::${rel.toEntityExternalKey}`));
1367
1462
  function buildEnrichmentInstructions() {
1368
1463
  const ecosystemLabel = detectedEcosystems.length > 0
1369
1464
  ? detectedEcosystems.join(", ")
@@ -1389,10 +1484,14 @@ ${subPackages.map((sp) => ` • ${sp.name} (${sp.relativePath})`).join("\n")
1389
1484
  then choose the correct entity type and subtype before running update-entity:
1390
1485
 
1391
1486
  CLASSIFICATION GUIDE — pick the best fit:
1392
- Deployable web app (has a dev/start/build script, runs in a browser or as a server)
1393
- → --entity-type application --subtype app_web
1394
- Deployable background service, worker, or data pipeline (runs as a process, no UI)
1395
- → --entity-type application --subtype app_custom_built (or app_integration_service for crawlers/ETL)
1487
+ Root project / top-level product application
1488
+ → --entity-type application --subtype app_custom_built
1489
+ Deployable sub-app under apps/* with UI
1490
+ → --entity-type application_component --subtype app_comp_ui
1491
+ Deployable sub-app under apps/* exposing API/backend service
1492
+ → --entity-type application_component --subtype app_comp_api
1493
+ Deployable background service, worker, job, ETL, or data pipeline under apps/*
1494
+ → --entity-type application_component --subtype app_comp_worker / app_comp_job / app_comp_etl / app_comp_data_pipeline
1396
1495
  Shared internal library or package (imported by other packages, not deployed on its own)
1397
1496
  → --entity-type technology_component --subtype tech_library
1398
1497
  Shared UI component library
@@ -1425,7 +1524,7 @@ ${subPackages.map((sp) => ` • ${sp.name} (${sp.relativePath})`).join("\n")
1425
1524
 
1426
1525
  ⚠️ DIRECTION MATTERS — wire relationships as follows:
1427
1526
 
1428
- Deployable sub-apps (apps/*) are PART OF the parent — child points to parent:
1527
+ Deployable sub-apps (apps/*) are PART OF the parent application — child points to parent:
1429
1528
  npx nexarch add-relationship \\
1430
1529
  --from "<sub-app-key>" \\
1431
1530
  --to "${projectExternalKey}" \\
@@ -1440,7 +1539,11 @@ ${subPackages.map((sp) => ` • ${sp.name} (${sp.relativePath})`).join("\n")
1440
1539
  (FROM = ${projectExternalKey}, TO = the library)
1441
1540
 
1442
1541
  ⚠️ WIRE DEPENDENCIES TO THE SUB-APP THAT DECLARES THEM, NOT TO THE PARENT.
1443
- After registering each sub-app/library entity, wire its npm dependencies to IT.
1542
+
1543
+ IMPORTANT: init-project has already written baseline dependency relationships from manifests.
1544
+ Do NOT re-run add-relationship for entries listed as pre-wired below unless one of these is true:
1545
+ • you changed the target entity key (e.g. you replaced a temporary key), or
1546
+ • you are intentionally adding/changing relationship attributes beyond the scanned defaults.
1444
1547
 
1445
1548
  RELATIONSHIP TYPE RULES:
1446
1549
  application → technology_component : --type "depends_on"
@@ -1448,7 +1551,7 @@ ${subPackages.map((sp) => ` • ${sp.name} (${sp.relativePath})`).join("\n")
1448
1551
  any → platform / platform_component : --type "runs_on" (NOT uses or depends_on)
1449
1552
  any → model : --type "uses_model"
1450
1553
 
1451
- Pre-resolved dependencies per sub-package (wire these after registering each entity):
1554
+ Pre-resolved dependencies per sub-package (already wired by init-project; review only):
1452
1555
  ${subPackages.map((sp) => {
1453
1556
  const resolved = sp.depSpecs
1454
1557
  .map((d) => resolvedByInput.get(d.name))
@@ -1457,9 +1560,11 @@ ${subPackages.map((sp) => {
1457
1560
  return ` • ${sp.name}: (no pre-resolved deps — check package.json manually)`;
1458
1561
  const lines = resolved.map((r) => {
1459
1562
  const relType = pickRelationshipType(r.entityTypeCode, sp.entityType);
1460
- return ` --to "${r.canonicalExternalRef}" --type "${relType}" # ${r.canonicalName}`;
1563
+ const key = `${relType}::${sp.externalKey}::${r.canonicalExternalRef}`;
1564
+ const status = preWiredRelationshipKeys.has(key) ? "[pre-wired]" : "[not pre-wired]";
1565
+ return ` ${status} --from "${sp.externalKey}" --to "${r.canonicalExternalRef}" --type "${relType}" # ${r.canonicalName}`;
1461
1566
  });
1462
- return ` • ${sp.name} (--from "${sp.externalKey}"):\n${lines.join("\n")}`;
1567
+ return ` • ${sp.name}:\n${lines.join("\n")}`;
1463
1568
  }).join("\n\n")}
1464
1569
  `;
1465
1570
  const adrStepNumber = subPackages.length > 0 ? "STEP 4" : "STEP 3";
@@ -1586,7 +1691,17 @@ ${subPkgSection}${adrSection}${gapCheckSection}`;
1586
1691
  const resolvedDeps = sp.depSpecs
1587
1692
  .map((d) => resolvedByInput.get(d.name))
1588
1693
  .filter((r) => !!r?.canonicalExternalRef)
1589
- .map((r) => ({ canonicalExternalRef: r.canonicalExternalRef, canonicalName: r.canonicalName, entityTypeCode: r.entityTypeCode }));
1694
+ .map((r) => {
1695
+ const relationshipTypeCode = pickRelationshipType(r.entityTypeCode, sp.entityType);
1696
+ const relationshipKey = `${relationshipTypeCode}::${sp.externalKey}::${r.canonicalExternalRef}`;
1697
+ return {
1698
+ canonicalExternalRef: r.canonicalExternalRef,
1699
+ canonicalName: r.canonicalName,
1700
+ entityTypeCode: r.entityTypeCode,
1701
+ relationshipTypeCode,
1702
+ preWired: preWiredRelationshipKeys.has(relationshipKey),
1703
+ };
1704
+ });
1590
1705
  return {
1591
1706
  name: sp.name,
1592
1707
  relativePath: sp.relativePath,
@@ -1603,12 +1718,27 @@ ${subPkgSection}${adrSection}${gapCheckSection}`;
1603
1718
  project: { name: displayName, externalKey: projectExternalKey, entityType: entityTypeOverride, detectedEcosystems },
1604
1719
  entities: entitiesResult.summary ?? {},
1605
1720
  relationships: relsResult?.summary ?? { requested: 0, succeeded: 0, failed: 0 },
1721
+ metrics: {
1722
+ entitiesSubmitted: entities.length,
1723
+ relationshipsSubmitted: relationships.length,
1724
+ relationshipAddAttempts,
1725
+ relationshipsSkippedAsDuplicate: relationshipSkippedAsDuplicate,
1726
+ },
1606
1727
  resolved: resolvedItems.length,
1607
1728
  unresolved: unresolvedItems.length,
1608
1729
  unresolvedSample: unresolvedItems.slice(0, 10).map((r) => r.input),
1609
1730
  entityErrors: entitiesResult.errors ?? [],
1610
1731
  relationshipErrors: relsResult?.errors ?? [],
1611
1732
  enrichmentTask,
1733
+ profile: profileEnabled
1734
+ ? {
1735
+ startedAt: profile.startedAt,
1736
+ totalDurationMs: Date.now() - commandStartedAt,
1737
+ events: profile.events,
1738
+ mcpCalls: profile.mcpCalls,
1739
+ upsertBatches: profile.upsertBatches,
1740
+ }
1741
+ : undefined,
1612
1742
  };
1613
1743
  if (asJson) {
1614
1744
  logProgress("complete", `ok=${output.ok}`);
@@ -1620,6 +1750,9 @@ ${subPkgSection}${adrSection}${gapCheckSection}`;
1620
1750
  console.log(`\nDone.`);
1621
1751
  console.log(` Entities : ${output.entities.succeeded ?? 0} written, ${output.entities.failed ?? 0} failed`);
1622
1752
  console.log(` Relationships: ${output.relationships.succeeded ?? 0} written`);
1753
+ if (output.metrics.relationshipsSkippedAsDuplicate > 0) {
1754
+ console.log(` Deduped rels : ${output.metrics.relationshipsSkippedAsDuplicate} skipped as duplicates before upsert`);
1755
+ }
1623
1756
  if (unresolvedItems.length > 0) {
1624
1757
  console.log(` Candidates : ${unresolvedItems.length} added to reference candidates`);
1625
1758
  }
@@ -81,26 +81,31 @@ function parseFindings(args) {
81
81
  if (!Array.isArray(parsed) || parsed.length === 0) {
82
82
  throw new Error(findingsFile ? "--findings-file must contain a non-empty JSON array" : "--findings-json must be a non-empty JSON array");
83
83
  }
84
- return parsed.map((item) => {
84
+ return parsed.map((item, index) => {
85
85
  const value = item;
86
- const policyControlId = String(value.policyControlId ?? value.controlId ?? "").trim();
87
- const policyRuleId = String(value.policyRuleId ?? value.ruleId ?? "").trim();
88
- const result = String(value.result ?? "").toLowerCase();
86
+ const policyControlId = String(value.policyControlId ?? value.controlId ?? value.policy_control_id ?? "").trim();
87
+ const policyRuleId = String(value.policyRuleId ?? value.ruleId ?? value.policy_rule_id ?? "").trim();
88
+ const result = String(value.result ?? value.status ?? "").toLowerCase();
89
89
  if (!policyControlId || !policyRuleId || !result) {
90
- throw new Error("Each finding must include policyControlId, policyRuleId, result. Tip: run 'nexarch policy-controls --entity <application:key> --json' and use the rule IDs from controls[].rules[].id");
90
+ throw new Error(`Finding #${index + 1} is missing required fields. Expected keys: policyControlId (or controlId/policy_control_id), policyRuleId (or ruleId/policy_rule_id), and result (or status). Example: [{\"policyControlId\":\"<uuid>\",\"policyRuleId\":\"<uuid>\",\"result\":\"pass\",\"rationale\":\"...\"}]`);
91
91
  }
92
92
  if (result !== "pass" && result !== "partial" && result !== "fail") {
93
- throw new Error(`Invalid finding result '${String(value.result)}'. Use pass|partial|fail.`);
93
+ throw new Error(`Invalid finding result '${String(value.result ?? value.status)}' at finding #${index + 1}. Use pass|partial|fail.`);
94
94
  }
95
95
  const rationale = value.rationale
96
96
  ? String(value.rationale)
97
97
  : [value.summary, value.evidence].filter(Boolean).map(String).join("\n\n");
98
+ const missingRequirements = Array.isArray(value.missingRequirements)
99
+ ? value.missingRequirements.map(String)
100
+ : Array.isArray(value.missing_requirements)
101
+ ? value.missing_requirements.map(String)
102
+ : undefined;
98
103
  return {
99
104
  policyControlId,
100
105
  policyRuleId,
101
106
  result,
102
107
  ...(rationale ? { rationale } : {}),
103
- ...(Array.isArray(value.missingRequirements) ? { missingRequirements: value.missingRequirements.map(String) } : {}),
108
+ ...(missingRequirements ? { missingRequirements } : {}),
104
109
  };
105
110
  });
106
111
  }
@@ -22,48 +22,124 @@ function parseAttributesInput(jsonText, filePath) {
22
22
  if (!jsonText && !filePath)
23
23
  return null;
24
24
  if (jsonText && filePath) {
25
- console.error("error: use only one of --attributes-json or --attributes-file");
26
- process.exit(1);
25
+ throw new Error("Use only one of --attributes-json or --attributes-file");
27
26
  }
28
27
  let raw = jsonText;
29
28
  if (filePath) {
30
29
  if (!existsSync(filePath)) {
31
- console.error(`error: --attributes-file not found: ${filePath}`);
32
- process.exit(1);
30
+ throw new Error(`--attributes-file not found: ${filePath}`);
33
31
  }
34
32
  raw = readFileSync(filePath, "utf8");
35
33
  }
36
34
  try {
37
35
  const parsed = JSON.parse(raw ?? "{}");
38
36
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
- console.error("error: attributes input must be a JSON object");
40
- process.exit(1);
37
+ throw new Error("Attributes input must be a JSON object");
41
38
  }
42
39
  return parsed;
43
40
  }
44
41
  catch (error) {
45
- console.error(`error: invalid attributes JSON (${error instanceof Error ? error.message : "parse failed"})`);
46
- process.exit(1);
42
+ throw new Error(`Invalid attributes JSON (${error instanceof Error ? error.message : "parse failed"})`);
47
43
  }
48
44
  }
45
+ function parseEntityBatch(args) {
46
+ const entitiesFile = parseOptionValue(args, "--entities-file");
47
+ const entitiesJson = parseOptionValue(args, "--entities-json");
48
+ if (!entitiesFile && !entitiesJson)
49
+ return null;
50
+ if (entitiesFile && entitiesJson)
51
+ throw new Error("Use only one of --entities-file or --entities-json");
52
+ let raw = entitiesJson;
53
+ if (entitiesFile) {
54
+ if (!existsSync(entitiesFile))
55
+ throw new Error(`--entities-file not found: ${entitiesFile}`);
56
+ raw = readFileSync(entitiesFile, "utf8");
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw ?? "[]");
61
+ }
62
+ catch {
63
+ throw new Error(entitiesFile ? "--entities-file must contain valid JSON array" : "--entities-json must be valid JSON array");
64
+ }
65
+ if (!Array.isArray(parsed) || parsed.length === 0) {
66
+ throw new Error("Entities input must be a non-empty JSON array");
67
+ }
68
+ return parsed.map((item, index) => {
69
+ const v = (item ?? {});
70
+ const externalKey = String(v.externalKey ?? v.key ?? "").trim();
71
+ if (!externalKey)
72
+ throw new Error(`Entity #${index + 1} is missing externalKey/key`);
73
+ const entityTypeCode = String(v.entityTypeCode ?? v.entity_type_code ?? "application").trim() || "application";
74
+ const entitySubtypeCode = String(v.entitySubtypeCode ?? v.entity_subtype_code ?? "").trim();
75
+ const out = {
76
+ externalKey,
77
+ entityTypeCode,
78
+ confidence: 1,
79
+ };
80
+ if (typeof v.name === "string" && v.name.trim())
81
+ out.name = v.name.trim();
82
+ if (typeof v.description === "string" && v.description.trim())
83
+ out.description = v.description.trim();
84
+ if (entitySubtypeCode)
85
+ out.entitySubtypeCode = entitySubtypeCode;
86
+ const attrs = {
87
+ ...(v.attributes && typeof v.attributes === "object" && !Array.isArray(v.attributes) ? v.attributes : {}),
88
+ };
89
+ if (typeof v.icon === "string" && v.icon.trim()) {
90
+ attrs.application_icon = { provider: "lucide", name: v.icon.trim() };
91
+ }
92
+ if (Object.keys(attrs).length > 0)
93
+ out.attributes = attrs;
94
+ if (!out.name && !out.description && !out.attributes) {
95
+ throw new Error(`Entity #${index + 1} must include at least one of name, description, icon, or attributes`);
96
+ }
97
+ return out;
98
+ });
99
+ }
49
100
  export async function updateEntity(args) {
50
101
  const asJson = parseFlag(args, "--json");
51
- const externalKey = parseOptionValue(args, "--key");
52
- const name = parseOptionValue(args, "--name");
53
- const description = parseOptionValue(args, "--description");
54
- const entityTypeCode = parseOptionValue(args, "--entity-type") ?? "application";
55
- const entitySubtypeCode = parseOptionValue(args, "--subtype");
56
- const iconName = parseOptionValue(args, "--icon");
57
- const attributesJson = parseOptionValue(args, "--attributes-json");
58
- const attributesFile = parseOptionValue(args, "--attributes-file");
59
- if (!externalKey) {
60
- console.error("error: --key <externalKey> is required");
61
- process.exit(1);
102
+ const batchEntities = parseEntityBatch(args);
103
+ let entities;
104
+ if (batchEntities) {
105
+ entities = batchEntities;
62
106
  }
63
- const explicitAttributes = parseAttributesInput(attributesJson, attributesFile);
64
- if (!name && !description && !iconName && !explicitAttributes) {
65
- console.error("error: provide at least one of --name, --description, --icon, --attributes-json, or --attributes-file");
66
- process.exit(1);
107
+ else {
108
+ const externalKey = parseOptionValue(args, "--key");
109
+ const name = parseOptionValue(args, "--name");
110
+ const description = parseOptionValue(args, "--description");
111
+ const entityTypeCode = parseOptionValue(args, "--entity-type") ?? "application";
112
+ const entitySubtypeCode = parseOptionValue(args, "--subtype");
113
+ const iconName = parseOptionValue(args, "--icon");
114
+ const attributesJson = parseOptionValue(args, "--attributes-json");
115
+ const attributesFile = parseOptionValue(args, "--attributes-file");
116
+ if (!externalKey)
117
+ throw new Error("--key <externalKey> is required (or use --entities-file/--entities-json)");
118
+ const explicitAttributes = parseAttributesInput(attributesJson, attributesFile);
119
+ if (!name && !description && !iconName && !explicitAttributes) {
120
+ throw new Error("Provide at least one of --name, --description, --icon, --attributes-json, or --attributes-file");
121
+ }
122
+ const entity = {
123
+ externalKey,
124
+ entityTypeCode,
125
+ confidence: 1,
126
+ };
127
+ if (name)
128
+ entity.name = name;
129
+ if (description)
130
+ entity.description = description;
131
+ if (entitySubtypeCode)
132
+ entity.entitySubtypeCode = entitySubtypeCode;
133
+ const attributes = {
134
+ ...(explicitAttributes ?? {}),
135
+ };
136
+ if (iconName) {
137
+ attributes.application_icon = { provider: "lucide", name: iconName };
138
+ }
139
+ if (Object.keys(attributes).length > 0) {
140
+ entity.attributes = attributes;
141
+ }
142
+ entities = [entity];
67
143
  }
68
144
  const creds = requireCredentials();
69
145
  const mcpOpts = { companyId: creds.companyId };
@@ -74,7 +150,7 @@ export async function updateEntity(args) {
74
150
  const agentContext = {
75
151
  agentId: "nexarch-cli:update-entity",
76
152
  agentRunId: `update-entity-${Date.now()}`,
77
- repoRef: externalKey,
153
+ repoRef: String(entities[0]?.externalKey ?? "batch"),
78
154
  observedAt: nowIso,
79
155
  source: "nexarch-cli",
80
156
  model: "n/a",
@@ -83,31 +159,7 @@ export async function updateEntity(args) {
83
159
  const policyContext = policyBundleHash
84
160
  ? { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } }
85
161
  : undefined;
86
- const entity = {
87
- externalKey,
88
- entityTypeCode,
89
- confidence: 1,
90
- };
91
- if (name)
92
- entity.name = name;
93
- if (description)
94
- entity.description = description;
95
- if (entitySubtypeCode)
96
- entity.entitySubtypeCode = entitySubtypeCode;
97
- const attributes = {
98
- ...(explicitAttributes ?? {}),
99
- };
100
- // Convenience sugar; still generic because user can fully control payload via --attributes-json/file.
101
- if (iconName) {
102
- attributes.application_icon = {
103
- provider: "lucide",
104
- name: iconName,
105
- };
106
- }
107
- if (Object.keys(attributes).length > 0) {
108
- entity.attributes = attributes;
109
- }
110
- const raw = await callMcpTool("nexarch_upsert_entities", { entities: [entity], agentContext, policyContext }, mcpOpts);
162
+ const raw = await callMcpTool("nexarch_upsert_entities", { entities, agentContext, policyContext }, mcpOpts);
111
163
  const result = parseToolText(raw);
112
164
  if (asJson) {
113
165
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -118,14 +170,17 @@ export async function updateEntity(args) {
118
170
  const succeeded = result.summary?.succeeded ?? 0;
119
171
  const failed = result.summary?.failed ?? 0;
120
172
  if (failed > 0) {
121
- console.error(`Failed to update entity: ${externalKey}`);
173
+ console.error(`Failed to update entities (${failed}/${entities.length}).`);
122
174
  for (const err of result.errors ?? []) {
123
175
  console.error(` ${err.externalKey}: ${err.error} — ${err.message}`);
124
176
  }
125
177
  process.exitCode = 1;
126
178
  return;
127
179
  }
128
- if (!asJson) {
129
- console.log(`Updated ${succeeded} entity: ${externalKey}`);
180
+ if (entities.length === 1) {
181
+ console.log(`Updated ${succeeded} entity: ${String(entities[0].externalKey)}`);
182
+ }
183
+ else {
184
+ console.log(`Updated ${succeeded}/${entities.length} entities.`);
130
185
  }
131
186
  }
package/dist/index.js CHANGED
@@ -95,27 +95,30 @@ Usage:
95
95
  --auto-map-application auto-map only when high confidence
96
96
  --non-interactive fail on ambiguous mapping
97
97
  --batch-size <n> upsert batch size (default: 10)
98
+ --profile include timing/profile data in JSON output
98
99
  --dry-run preview without writing
99
100
  --json
100
101
  nexarch update-entity
101
- Update the name and/or description of an existing graph entity.
102
- Use this after init-project to enrich the entity with meaningful
103
- content from the project README or docs.
104
- Options: --key <externalKey> (required)
105
- --name <name>
106
- --description <text>
107
- --entity-type <code> (default: application)
108
- --subtype <code>
109
- --icon <lucide-name> (convenience; sets attributes.application_icon)
110
- --attributes-json '<json object>'
111
- --attributes-file <path.json>
112
- --json
102
+ Update existing graph entities (single or batch).
103
+ Single options: --key <externalKey>
104
+ --name <name>
105
+ --description <text>
106
+ --entity-type <code> (default: application)
107
+ --subtype <code>
108
+ --icon <lucide-name> (sets attributes.application_icon)
109
+ --attributes-json '<json object>'
110
+ --attributes-file <path.json>
111
+ Batch options: --entities-json '<json array>'
112
+ --entities-file <path.json>
113
+ --json
113
114
  nexarch add-relationship
114
- Add a relationship between two existing graph entities.
115
- Options: --from <externalKey> (required)
116
- --to <externalKey> (required)
117
- --type <code> (required, e.g. part_of, depends_on)
118
- --json
115
+ Add relationships between existing graph entities (single or batch).
116
+ Single options: --from <externalKey>
117
+ --to <externalKey>
118
+ --type <code> (e.g. part_of, depends_on)
119
+ Batch options: --relationships-json '<json array>'
120
+ --relationships-file <path.json>
121
+ --json
119
122
  nexarch register-alias
120
123
  Register a company-scoped alias for an entity so future
121
124
  scans resolve it instead of logging it as a candidate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",