nexarch 0.13.0 → 0.13.2

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.
@@ -5,7 +5,7 @@ import { basename, join, relative, resolve as resolvePath } from "node:path";
5
5
  import { requireCredentials } from "../lib/credentials.js";
6
6
  import { callMcpTool } from "../lib/mcp.js";
7
7
  import { buildVersionAttributes } from "../lib/version-normalization.js";
8
- import { detectInfrastructureProject } from "../lib/terraform-detect.js";
8
+ import { detectInfrastructureProject } from "../lib/iac-detect.js";
9
9
  import { runInfrastructureOnboarding } from "./init-project-infra.js";
10
10
  import { detectDataProject } from "../lib/data-project-detect.js";
11
11
  import { runDataProjectOnboarding } from "./init-project-data.js";
@@ -1238,6 +1238,165 @@ export function scoreApplicationCandidate(app, projectName, repoUrl, homepageUrl
1238
1238
  return null;
1239
1239
  return { entityRef, name: app.name, score: Math.min(1, score), reasons };
1240
1240
  }
1241
+ /** Every `--map-deployable` value, in order. The flag may be repeated. */
1242
+ export function parseDeployableMappings(args) {
1243
+ const mappings = [];
1244
+ for (let i = 0; i < args.length; i += 1) {
1245
+ if (args[i] !== "--map-deployable")
1246
+ continue;
1247
+ const value = args[i + 1] ?? "";
1248
+ const eq = value.indexOf("=");
1249
+ mappings.push(eq > 0 ? { deployable: value.slice(0, eq).trim(), entityRef: value.slice(eq + 1).trim() } : { deployable: value, entityRef: "" });
1250
+ }
1251
+ return mappings;
1252
+ }
1253
+ /**
1254
+ * Points deployables at applications that already exist.
1255
+ *
1256
+ * A deployable is named by its directory (`web`) or its path (`apps/web`). The
1257
+ * errors are returned rather than thrown so the caller can refuse before
1258
+ * writing anything, naming every problem at once.
1259
+ */
1260
+ export function applyDeployableMappings(subPackages, mappings) {
1261
+ const errors = [];
1262
+ for (const mapping of mappings) {
1263
+ if (!mapping.deployable || !mapping.entityRef.startsWith("application:")) {
1264
+ errors.push(`--map-deployable expects <deployable>=application:<ref>, got "${mapping.deployable}${mapping.entityRef ? `=${mapping.entityRef}` : ""}".`);
1265
+ continue;
1266
+ }
1267
+ const target = subPackages.find((sp) => isApplicationLikeEntityType(sp.entityType)
1268
+ && (sp.relativePath === mapping.deployable || basename(sp.relativePath) === mapping.deployable));
1269
+ if (!target) {
1270
+ const known = subPackages.filter((sp) => isApplicationLikeEntityType(sp.entityType)).map((sp) => sp.relativePath);
1271
+ errors.push(`--map-deployable: no deployable called "${mapping.deployable}". This repository has: ${known.join(", ") || "none"}.`);
1272
+ continue;
1273
+ }
1274
+ target.mappedFrom = target.mappedFrom ?? target.externalKey;
1275
+ target.externalKey = mapping.entityRef;
1276
+ target.mappedBy = "flag";
1277
+ }
1278
+ return errors;
1279
+ }
1280
+ /**
1281
+ * Whether to register, or stop and ask first.
1282
+ *
1283
+ * Paul's decision (16 September): when an application already in the workspace
1284
+ * resembles what a scan is about to register, the scan writes NOTHING and asks.
1285
+ * ADR 8d had made the default "create a proposed application and flag it", and
1286
+ * even once agents were told to stop, the proposed duplicate already existed.
1287
+ *
1288
+ * Only a NEW application can be a duplicate. On a re-run everything already
1289
+ * registered was decided the first time, so asking again every run would be
1290
+ * noise -- and the matches are dropped rather than reported, for the same
1291
+ * reason. `--create-application` is the human having decided.
1292
+ */
1293
+ export function registrationGate(params) {
1294
+ if (params.createAnyway || params.newApplicationRefs.length === 0)
1295
+ return { stop: false, similar: [] };
1296
+ return { stop: params.similar.length > 0, similar: params.similar };
1297
+ }
1298
+ /**
1299
+ * The registration status an agent acts on.
1300
+ *
1301
+ * A possible duplicate outranks everything. In 0.13.1 the output said "Do not
1302
+ * pause or ask for confirmation between steps" in the same object whose first
1303
+ * step said "tell the human before enriching anything", and CLAUDE.md's
1304
+ * completion contract says stopping early is not a finished task. Told twice to
1305
+ * keep going and once to stop, an agent kept going and enriched the duplicate
1306
+ * -- on Paul's 16 September re-run, which the similarity check had correctly
1307
+ * flagged.
1308
+ */
1309
+ export function enrichmentStatusFor(params) {
1310
+ if (params.similarApplicationCount > 0)
1311
+ return "review_required";
1312
+ if (params.refreshMode && params.nothingWritten && params.preservedEntityCount > 0)
1313
+ return "review_enrichment";
1314
+ return "enrichment_required";
1315
+ }
1316
+ export const REVIEW_REQUIRED_NOTE = "STOP. Nothing was written. Existing applications in this workspace resemble what this scan would register"
1317
+ + " (see similarApplications). Tell the human, say which one you think is the same system and why, and wait for"
1318
+ + " their answer. Then re-run init-project with the command in `decision.commands` that matches their choice.";
1319
+ /**
1320
+ * The possible-duplicate warning for the text directive, first thing in it.
1321
+ *
1322
+ * The directive goes to stderr precisely so an agent sees it without parsing
1323
+ * JSON, and it used to carry no duplicate warning at all -- the only one was a
1324
+ * JSON field under a note saying not to pause.
1325
+ */
1326
+ export function possibleDuplicateDirective(matches, isMonorepo) {
1327
+ if (matches.length === 0)
1328
+ return [];
1329
+ return [
1330
+ "",
1331
+ "STOP — POSSIBLE DUPLICATE. Nothing was written.",
1332
+ "Existing applications in this workspace resemble what this scan would register:",
1333
+ ...matches.map((m) => ` ${m.name} (${m.entityRef}) score ${m.score.toFixed(2)} [${m.reasons.join(", ")}]`),
1334
+ isMonorepo
1335
+ ? "Tell the human which deployable you think is the same system, and why. Then re-run with their choice:"
1336
+ : "Tell the human which one you think is the same application, and why. Then re-run with their choice:",
1337
+ isMonorepo
1338
+ ? " same system npx nexarch@latest init-project --dir . --map-deployable <deployable>=<entityRef>"
1339
+ : " same application npx nexarch@latest init-project --dir . --application-ref <entityRef>",
1340
+ " all different npx nexarch@latest init-project --dir . --create-application",
1341
+ ];
1342
+ }
1343
+ /**
1344
+ * For each detected name, the entity_refs of what declares it.
1345
+ *
1346
+ * Sent with name resolution so that a name not yet in the catalogue is recorded
1347
+ * against the thing that depends on it. Without it, adding `elkjs` to the
1348
+ * catalogue later added it to every workspace that had seen it -- attached to
1349
+ * nothing, because the sighting only knew the repository, and on a first scan
1350
+ * not even that.
1351
+ *
1352
+ * Only application-like packages are dependents: the re-resolve job writes the
1353
+ * edge directly, and a shared library depending on a technology is not an edge
1354
+ * the ontology draws. A monorepo's root manifest is repository tooling, not a
1355
+ * deployable, so it has no dependent; a single-package repository's root is
1356
+ * the application.
1357
+ */
1358
+ export function dependentRefsForNames(params) {
1359
+ const refs = new Map();
1360
+ const declare = (name, ref) => {
1361
+ if (!refs.has(name))
1362
+ refs.set(name, new Set());
1363
+ refs.get(name).add(ref);
1364
+ };
1365
+ for (const sp of params.subPackages) {
1366
+ if (!isApplicationLikeEntityType(sp.entityType) || !sp.externalKey)
1367
+ continue;
1368
+ for (const dep of sp.depSpecs)
1369
+ declare(dep.name, sp.externalKey);
1370
+ }
1371
+ if (params.rootDependentRef) {
1372
+ for (const name of params.rootDepNames)
1373
+ declare(name, params.rootDependentRef);
1374
+ }
1375
+ return Object.fromEntries([...refs].map(([name, set]) => [name, [...set].sort()]));
1376
+ }
1377
+ /**
1378
+ * Existing applications that resemble a repository, for a monorepo.
1379
+ *
1380
+ * Scored on the repository, not on each deployable: per-deployable name
1381
+ * scoring reports the wrong thing. A CLI whose npm name is the product name
1382
+ * scores as a duplicate of the product's website, and short names like "web"
1383
+ * match anything they happen to be a substring of.
1384
+ *
1385
+ * `ownRefs` are this repository's own deployables. They exist on a re-run and
1386
+ * every one contains the repository name, so without excluding them a second
1387
+ * scan reports the repository as a duplicate of itself.
1388
+ */
1389
+ export function similarApplicationsForRepository(apps, params) {
1390
+ return apps
1391
+ .filter((app) => {
1392
+ const ref = app.entityRef ?? app.externalKey;
1393
+ return Boolean(ref) && !params.ownRefs.has(ref);
1394
+ })
1395
+ .map((app) => scoreApplicationCandidate(app, params.repositoryName, params.repoUrl, params.homepageUrl))
1396
+ .filter((match) => Boolean(match))
1397
+ .sort((a, b) => b.score - a.score)
1398
+ .slice(0, 5);
1399
+ }
1241
1400
  // ─── Main command ─────────────────────────────────────────────────────────────
1242
1401
  export async function initProject(args) {
1243
1402
  const asJson = parseFlag(args, "--json");
@@ -1307,6 +1466,7 @@ export async function initProject(args) {
1307
1466
  nonInteractive: parseFlag(args, "--non-interactive"),
1308
1467
  skipIngest: parseFlag(args, "--no-ingest"),
1309
1468
  dryRun,
1469
+ tool: parseOptionValue(args, "--tool"),
1310
1470
  }, infrastructure);
1311
1471
  }
1312
1472
  return;
@@ -1325,6 +1485,7 @@ export async function initProject(args) {
1325
1485
  nonInteractive: parseFlag(args, "--non-interactive"),
1326
1486
  skipIngest: parseFlag(args, "--no-ingest"),
1327
1487
  dryRun,
1488
+ tool: parseOptionValue(args, "--tool"),
1328
1489
  }, infrastructure);
1329
1490
  return;
1330
1491
  }
@@ -1381,6 +1542,42 @@ export async function initProject(args) {
1381
1542
  seenSubSlugs.add(dirSlug);
1382
1543
  sp.externalKey = `${sp.entityType}:${projectDirSlug}_${dirSlug}`;
1383
1544
  }
1545
+ // Refuse bad mappings before anything is written, naming every problem.
1546
+ const deployableMappings = parseDeployableMappings(args);
1547
+ if (deployableMappings.length > 0) {
1548
+ const mappingErrors = isMonorepo
1549
+ ? applyDeployableMappings(subPackages, deployableMappings)
1550
+ : ["--map-deployable applies to a repository with several deployables. For a single-package repository use --application-ref."];
1551
+ if (mappingErrors.length > 0)
1552
+ throw new Error(mappingErrors.join("\n"));
1553
+ }
1554
+ // A merge leaves the losing application's ref behind as an alias of the
1555
+ // survivor. Without honouring it, the next scan of the repository would
1556
+ // re-create the application a human had just merged away. Looked up with
1557
+ // recordUnresolved: false -- an unmapped "application:acme_web" is not a
1558
+ // technology to put in front of catalogue reviewers.
1559
+ if (projectConstruct && isMonorepo && !forceCreateApplication) {
1560
+ const unmapped = subPackages.filter((sp) => isApplicationLikeEntityType(sp.entityType) && !sp.mappedFrom);
1561
+ if (unmapped.length > 0) {
1562
+ try {
1563
+ const aliasRaw = await callMcpProfiled("nexarch_resolve_reference", { names: unmapped.map((sp) => sp.externalKey), companyId: creds.companyId, recordUnresolved: false }, { phase: "deployable.alias" });
1564
+ for (const hit of parseToolText(aliasRaw).results ?? []) {
1565
+ const sp = unmapped.find((candidate) => candidate.externalKey === hit.input);
1566
+ const ref = hit.canonicalExternalRef;
1567
+ if (!sp || !hit.resolved || hit.entityTypeCode !== "application" || !ref?.startsWith("application:") || ref === sp.externalKey)
1568
+ continue;
1569
+ sp.mappedFrom = sp.externalKey;
1570
+ sp.externalKey = ref;
1571
+ sp.mappedBy = "alias";
1572
+ if (!asJson)
1573
+ console.log(` Mapped : ${sp.relativePath} → ${ref} (${hit.canonicalName ?? "existing application"}), from an earlier merge`);
1574
+ }
1575
+ }
1576
+ catch {
1577
+ // Best effort. If the lookup fails, the duplicate check below still runs.
1578
+ }
1579
+ }
1580
+ }
1384
1581
  if (!asJson) {
1385
1582
  console.log(` Project : ${displayName} (${entityTypeOverride})`);
1386
1583
  console.log(` Packages: ${packageJsonCount} package.json file(s)`);
@@ -1399,11 +1596,17 @@ export async function initProject(args) {
1399
1596
  console.log("\nResolving against reference library…");
1400
1597
  const allResolveResults = [];
1401
1598
  const BATCH_SIZE = 200;
1599
+ const dependentRefsByName = dependentRefsForNames({
1600
+ subPackages,
1601
+ rootDepNames,
1602
+ rootDependentRef: isMonorepo ? null : projectExternalKey,
1603
+ });
1402
1604
  logProgress("resolve.start", `count=${detectedNames.length}, batchSize=${BATCH_SIZE}`);
1403
1605
  for (let i = 0; i < detectedNames.length; i += BATCH_SIZE) {
1404
1606
  const batch = detectedNames.slice(i, i + BATCH_SIZE);
1405
1607
  logProgress("resolve.batch", `${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(detectedNames.length / BATCH_SIZE)} size=${batch.length}`);
1406
- const raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId, projectRef: projectEntityKey }, { batchSize: batch.length });
1608
+ const dependentRefs = Object.fromEntries(batch.filter((name) => dependentRefsByName[name]?.length).map((name) => [name, dependentRefsByName[name]]));
1609
+ const raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId, projectRef: projectEntityKey, dependentRefs }, { batchSize: batch.length });
1407
1610
  const data = parseToolText(raw);
1408
1611
  allResolveResults.push(...data.results);
1409
1612
  }
@@ -1477,6 +1680,10 @@ export async function initProject(args) {
1477
1680
  const sourceProvider = detectedRepo?.provider ?? "unknown";
1478
1681
  const declaredHomepage = readDeclaredHomepage(dir);
1479
1682
  let similarApplications = [];
1683
+ // Set when the scan must stop and ask instead of registering anything.
1684
+ let stopForReview = null;
1685
+ // Names of applications already in the workspace, by ref, for mapped deployables.
1686
+ const existingApplicationNames = new Map();
1480
1687
  if (projectConstruct && isMonorepo && (applicationRefOverride || forceCreateApplication) && !asJson) {
1481
1688
  console.log("\nNote: monorepo detected — no root application is created (ADR 8a); --application-ref/--create-application apply only to single-package repositories.");
1482
1689
  }
@@ -1510,16 +1717,18 @@ export async function initProject(args) {
1510
1717
  if (!applicationRefOverride && projectExternalKey === `${entityTypeOverride}:${projectSlug}`) {
1511
1718
  const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500 });
1512
1719
  const appsData = parseToolText(appsRaw);
1513
- const apps = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
1720
+ const listed = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
1721
+ // This repository's own application, on a re-run, matches its own name
1722
+ // exactly. Now that a match stops the scan, that would stop every re-run.
1723
+ const alreadyRegistered = listed.some((e) => (e.entityRef ?? e.externalKey) === projectExternalKey);
1724
+ const apps = listed.filter((e) => (e.entityRef ?? e.externalKey) !== projectExternalKey);
1514
1725
  if (apps.length > 0) {
1515
1726
  const matches = apps
1516
1727
  .map((a) => scoreApplicationCandidate(a, displayName, repoUrl, declaredHomepage))
1517
1728
  .filter((m) => Boolean(m))
1518
1729
  .sort((a, b) => b.score - a.score);
1519
- // Captured for the JSON output. Until this existed the branch below was
1520
- // guarded by `!asJson`, so an agent -- which always runs --json -- got a
1521
- // result with no hint that a similar application already existed, and
1522
- // reported the new proposal as if nothing resembled it.
1730
+ // Captured for the JSON output, not only printed: an agent always runs
1731
+ // --json, and a match it never sees is a match nobody acts on.
1523
1732
  similarApplications = matches.slice(0, 5);
1524
1733
  const suggested = matches.length > 0 ? matches[0] : null;
1525
1734
  const highConfidence = suggested && suggested.score >= 0.85;
@@ -1533,19 +1742,105 @@ export async function initProject(args) {
1533
1742
  // remains available via --application-ref and --auto-map-application.
1534
1743
  if (autoMapApplication && highConfidence) {
1535
1744
  projectExternalKey = suggested.entityRef;
1745
+ similarApplications = [];
1536
1746
  if (!asJson)
1537
1747
  console.log(`\nAuto-mapped to existing application: ${suggested.name} (${projectExternalKey})`);
1538
1748
  }
1539
- else if (matches.length > 0 && !asJson) {
1540
- console.log("\nSimilar existing applications found (registering a new proposed application anyway — the reviewer sees these again at activation):");
1541
- for (const m of matches.slice(0, 5)) {
1542
- console.log(` - ${m.name} (${m.entityRef}) score=${m.score.toFixed(2)} [${m.reasons.join(", ")}]`);
1543
- }
1544
- console.log(" To map to one instead, re-run with: --application-ref <entityRef>");
1749
+ else {
1750
+ const gate = registrationGate({
1751
+ similar: similarApplications,
1752
+ newApplicationRefs: alreadyRegistered ? [] : [projectExternalKey],
1753
+ createAnyway: false,
1754
+ });
1755
+ similarApplications = gate.similar;
1756
+ if (gate.stop)
1757
+ stopForReview = { newApplications: [{ deployable: basename(dir), entityRef: projectExternalKey }] };
1545
1758
  }
1546
1759
  }
1547
1760
  }
1548
1761
  }
1762
+ // The branch above is gated on !isMonorepo, so for a monorepo -- including
1763
+ // this repository -- the duplicate check never ran at all. Paul's 16
1764
+ // September test registered web, mcp-gateway and nexarch-cli into a
1765
+ // workspace that already held "nexarch.ai website" from onboarding, and
1766
+ // nothing was flagged: item 17's homepage scoring simply never executed.
1767
+ //
1768
+ // Scored at REPOSITORY level, not per deployable. Name scoring per deployable
1769
+ // is worse than silence here: the CLI's npm name is literally "nexarch", so it
1770
+ // scores 0.5 against the website and would be reported as its duplicate, and
1771
+ // "web" matches only because it is a substring of "website". The repository
1772
+ // name is the honest signal, and which deployable (if any) is the same system
1773
+ // is a judgement for a human -- the step below says so.
1774
+ if (projectConstruct && isMonorepo && !forceCreateApplication) {
1775
+ // This repository's own deployables exist on a re-run, and every one of
1776
+ // them contains the repository name. Without excluding them, a second run
1777
+ // reports the repo as a duplicate of itself.
1778
+ const ownRefs = new Set(subPackages.map((sp) => sp.externalKey));
1779
+ const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500, phase: "monorepo.similar" });
1780
+ const listed = parseToolText(appsRaw).entities ?? [];
1781
+ for (const entity of listed) {
1782
+ const ref = entity.entityRef ?? entity.externalKey;
1783
+ if (ref)
1784
+ existingApplicationNames.set(ref, entity.name);
1785
+ }
1786
+ // A mapped deployable writes no stub of its own, so a mapping to an
1787
+ // application that is not there would leave its relationships pointing at
1788
+ // nothing. Refuse before writing.
1789
+ const badTargets = subPackages.filter((sp) => sp.mappedBy === "flag" && !existingApplicationNames.has(sp.externalKey));
1790
+ if (badTargets.length > 0) {
1791
+ throw new Error(badTargets
1792
+ .map((sp) => `--map-deployable: ${sp.externalKey} is not an active application in this workspace (mapping ${sp.relativePath}).`)
1793
+ .join("\n"));
1794
+ }
1795
+ const newDeployables = subPackages.filter((sp) => isApplicationLikeEntityType(sp.entityType) && !existingApplicationNames.has(sp.externalKey));
1796
+ const gate = registrationGate({
1797
+ similar: similarApplicationsForRepository(listed, { repositoryName: displayName, repoUrl, homepageUrl: declaredHomepage, ownRefs }),
1798
+ newApplicationRefs: newDeployables.map((sp) => sp.externalKey),
1799
+ createAnyway: false,
1800
+ });
1801
+ similarApplications = gate.similar;
1802
+ if (gate.stop) {
1803
+ stopForReview = {
1804
+ newApplications: newDeployables.map((sp) => ({ deployable: basename(sp.relativePath), entityRef: sp.externalKey })),
1805
+ };
1806
+ }
1807
+ }
1808
+ // Paul's decision: a possible duplicate stops the scan before it writes
1809
+ // anything. Resolution has already run (it only reads, and logs names not in
1810
+ // the catalogue); nothing below this point has.
1811
+ if (stopForReview) {
1812
+ const directive = possibleDuplicateDirective(similarApplications, isMonorepo);
1813
+ const commands = isMonorepo
1814
+ ? {
1815
+ sameSystem: "npx nexarch@latest init-project --dir . --map-deployable <deployable>=<entityRef>",
1816
+ allDifferent: "npx nexarch@latest init-project --dir . --create-application",
1817
+ }
1818
+ : {
1819
+ sameApplication: "npx nexarch@latest init-project --dir . --application-ref <entityRef>",
1820
+ allDifferent: "npx nexarch@latest init-project --dir . --create-application",
1821
+ };
1822
+ if (asJson) {
1823
+ process.stdout.write(`${JSON.stringify({
1824
+ ok: true,
1825
+ status: "review_required",
1826
+ wrote: false,
1827
+ project: projectConstruct
1828
+ ? { name: projectDirName, externalKey: projectEntityKey, entityType: "project", subtype: isMonorepo ? "project_monorepo" : "project_repository" }
1829
+ : { name: displayName, externalKey: projectExternalKey, entityType: entityTypeOverride },
1830
+ similarApplications,
1831
+ newApplications: stopForReview.newApplications,
1832
+ decision: { note: REVIEW_REQUIRED_NOTE, commands },
1833
+ }, null, 2)}\n`);
1834
+ process.stderr.write(`${directive.join("\n")}\n`);
1835
+ }
1836
+ else {
1837
+ console.log(directive.join("\n"));
1838
+ console.log("\nWould have registered:");
1839
+ for (const app of stopForReview.newApplications)
1840
+ console.log(` ${app.deployable} → ${app.entityRef}`);
1841
+ }
1842
+ return;
1843
+ }
1549
1844
  logProgress(projectConstruct ? "project.target" : "application.target", projectConstruct ? (isMonorepo ? projectEntityKey : `${projectEntityKey} + ${projectExternalKey}`) : projectExternalKey);
1550
1845
  // In refresh mode, snapshot the current graph state for this project before writing,
1551
1846
  // so we can diff what changed and surface stale relationships to the agent.
@@ -1664,6 +1959,10 @@ export async function initProject(args) {
1664
1959
  if (seenSubKeys.has(sp.externalKey))
1665
1960
  continue;
1666
1961
  seenSubKeys.add(sp.externalKey);
1962
+ // The application already exists and a human may have named and described
1963
+ // it. Its relationships below still use its ref.
1964
+ if (sp.mappedFrom)
1965
+ continue;
1667
1966
  const dirDisplayName = basename(sp.relativePath);
1668
1967
  entities.push({
1669
1968
  externalKey: sp.externalKey,
@@ -1873,6 +2172,25 @@ export async function initProject(args) {
1873
2172
  // ability to resolve via tier 2; the scan itself already succeeded.
1874
2173
  }
1875
2174
  }
2175
+ // Remember an explicit --map-deployable, so the next scan does not ask again.
2176
+ // A merge registers the same alias; this is the same decision made up front.
2177
+ for (const sp of subPackages) {
2178
+ if (sp.mappedBy !== "flag" || !sp.mappedFrom)
2179
+ continue;
2180
+ try {
2181
+ await callMcpTool("nexarch_register_alias", {
2182
+ alias: sp.mappedFrom,
2183
+ canonicalExternalKey: sp.externalKey,
2184
+ canonicalName: existingApplicationNames.get(sp.externalKey) ?? sp.externalKey,
2185
+ entityTypeCode: "application",
2186
+ description: `Registered by init-project: the ${sp.relativePath} deployable is this application.`,
2187
+ companyId: creds.companyId,
2188
+ }, { companyId: creds.companyId });
2189
+ }
2190
+ catch {
2191
+ // Non-fatal: without it the next scan asks again, which is safe.
2192
+ }
2193
+ }
1876
2194
  // Upsert relationships (chunked)
1877
2195
  let relsResult = null;
1878
2196
  if (relationships.length > 0) {
@@ -1925,13 +2243,36 @@ export async function initProject(args) {
1925
2243
  // this application already exists under another name, everything after this
1926
2244
  // enriches a duplicate. The scan has always computed these matches; until
1927
2245
  // now it printed them only in human mode, so the agent never saw them.
1928
- if (similarApplications.length > 0) {
2246
+ const matchList = similarApplications
2247
+ .map((m) => `${m.name} (${m.entityRef}, score ${m.score.toFixed(2)}, ${m.reasons.join(", ")})`)
2248
+ .join("; ");
2249
+ if (similarApplications.length > 0 && isMonorepo) {
2250
+ // --application-ref does not apply to a monorepo (the CLI says so above),
2251
+ // so the single-repo remedy would hand the agent a command that does
2252
+ // nothing. The resolution here is a merge, which a human does on the
2253
+ // application page.
1929
2254
  pendingSteps.push({
1930
2255
  step: stepNum++,
1931
2256
  action: "review_similar_applications",
1932
2257
  instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
1933
- + ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered:`
1934
- + ` ${similarApplications.map((m) => `${m.name} (${m.entityRef}, score ${m.score.toFixed(2)}, ${m.reasons.join(", ")})`).join("; ")}.`
2258
+ + ` workspace resemble${similarApplications.length === 1 ? "s" : ""} this repository: ${matchList}.`
2259
+ + " One of the deployables just registered may be the same system — for example a web app registered"
2260
+ + " earlier from its public website. Tell the human which deployable you think matches and why, before"
2261
+ + " enriching anything. The new deployables are PROPOSED, so nothing is broken yet; if one is a"
2262
+ + " duplicate, the human merges the two with \"Merge with…\" on the application's page.",
2263
+ notes: [
2264
+ "Do not report the registration as complete without mentioning these.",
2265
+ "The match is on the repository, not a specific deployable: say which one you believe it is, and why.",
2266
+ "A human decides which is the same application — do not merge or decline on your own judgement.",
2267
+ ],
2268
+ });
2269
+ }
2270
+ else if (similarApplications.length > 0) {
2271
+ pendingSteps.push({
2272
+ step: stepNum++,
2273
+ action: "review_similar_applications",
2274
+ instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
2275
+ + ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered: ${matchList}.`
1935
2276
  + " Tell the human about these before enriching anything. This registration was created as a"
1936
2277
  + " PROPOSED application and is not in the canonical graph yet, so nothing is broken — but if one of"
1937
2278
  + " these is the same application, it should be mapped rather than duplicated.",
@@ -2026,16 +2367,21 @@ export async function initProject(args) {
2026
2367
  });
2027
2368
  const preservedEntities = entitiesResult.preserved ?? [];
2028
2369
  const nothingWritten = (entitiesResult.summary?.succeeded ?? 0) === 0;
2029
- const status = refreshMode && nothingWritten && preservedEntities.length > 0
2030
- ? "review_enrichment"
2031
- : "enrichment_required";
2370
+ const status = enrichmentStatusFor({
2371
+ similarApplicationCount: similarApplications.length,
2372
+ refreshMode,
2373
+ nothingWritten,
2374
+ preservedEntityCount: preservedEntities.length,
2375
+ });
2032
2376
  return {
2033
2377
  status,
2034
- note: status === "review_enrichment"
2035
- ? "No new skeletons were written — all entities were already enriched and preserved. Review the preservedEntities list and confirm scan values match. Update with nexarch update-entity only if something has genuinely changed."
2036
- : refreshMode
2037
- ? "Execute ALL pendingSteps. In refresh mode, compare graphState vs scanState to identify gaps — do not blindly remove relationships the scanner missed; the scanner cannot detect platforms, integrations, data assets, or application_functions."
2038
- : "Execute ALL pendingSteps before reporting the project as registered. Do not pause or ask for confirmation between steps.",
2378
+ note: status === "review_required"
2379
+ ? REVIEW_REQUIRED_NOTE
2380
+ : status === "review_enrichment"
2381
+ ? "No new skeletons were written — all entities were already enriched and preserved. Review the preservedEntities list and confirm scan values match. Update with nexarch update-entity only if something has genuinely changed."
2382
+ : refreshMode
2383
+ ? "Execute ALL pendingSteps. In refresh mode, compare graphState vs scanState to identify gaps — do not blindly remove relationships the scanner missed; the scanner cannot detect platforms, integrations, data assets, or application_functions."
2384
+ : "Execute ALL pendingSteps before reporting the project as registered. Do not pause or ask for confirmation between steps.",
2039
2385
  ...(refreshMode && preservedEntities.length > 0
2040
2386
  ? {
2041
2387
  preservedEntities: {
@@ -2157,6 +2503,7 @@ export async function initProject(args) {
2157
2503
  lines.push("init-project wrote a skeleton. The steps below complete the registration.");
2158
2504
  lines.push("Until they are done, this project is registered as a skeleton only.");
2159
2505
  }
2506
+ lines.push(...possibleDuplicateDirective(similarApplications, isMonorepo));
2160
2507
  lines.push("");
2161
2508
  lines.push(`PROJECT : ${projectConstruct ? `${projectEntityKey} (${isMonorepo ? "monorepo" : "repository"})` : projectExternalKey}`);
2162
2509
  lines.push(`DIR : ${dir}`);
@@ -2181,6 +2528,12 @@ export async function initProject(args) {
2181
2528
  const conf = subPackageConfidence(sp);
2182
2529
  lines.push(` ${sp.relativePath}`);
2183
2530
  lines.push(` key : ${sp.externalKey}`);
2531
+ if (sp.mappedFrom) {
2532
+ // "Confirm type, then update" is right for a new skeleton and wrong
2533
+ // here: this is an application a human already registered and named.
2534
+ lines.push(` MAPPED : this deployable is the existing application ${sp.externalKey}${sp.mappedBy === "alias" ? " (from an earlier merge)" : ""}.`);
2535
+ lines.push(" Add what the code shows; do not rename it or replace its description.");
2536
+ }
2184
2537
  lines.push(` inferred : ${sp.entityType} / ${sp.subtype} (confidence ${conf.toFixed(2)})`);
2185
2538
  const unresolvedDeps = sp.depSpecs.map((d) => d.name).filter((d) => !resolvedByInput.has(d));
2186
2539
  if (unresolvedDeps.length > 0) {
@@ -2341,7 +2694,11 @@ export async function initProject(args) {
2341
2694
  const preservedCount = output.entities.preserved ?? 0;
2342
2695
  console.log(` Entities : ${output.entities.succeeded ?? 0} written, ${preservedCount > 0 ? `${preservedCount} preserved (enriched), ` : ""}${output.entities.failed ?? 0} failed`);
2343
2696
  console.log(` Relationships: ${output.relationships.succeeded ?? 0} written`);
2344
- console.log(` Status : ${output.status === "review_enrichment" ? "all enriched; review preserved entities" : "skeleton created; enrichment pending"}`);
2697
+ console.log(` Status : ${output.status === "review_required"
2698
+ ? "possible duplicate; ask before enriching"
2699
+ : output.status === "review_enrichment"
2700
+ ? "all enriched; review preserved entities"
2701
+ : "skeleton created; enrichment pending"}`);
2345
2702
  if (output.metrics.relationshipsSkippedAsDuplicate > 0) {
2346
2703
  console.log(` Deduped rels : ${output.metrics.relationshipsSkippedAsDuplicate} skipped as duplicates before upsert`);
2347
2704
  }
package/dist/index.js CHANGED
@@ -166,12 +166,18 @@ Usage:
166
166
  --name <name> override project name
167
167
  --entity-type <code> (default: application)
168
168
  --application-ref <entityRef> force mapping target
169
- --create-application force new application entity
169
+ --map-deployable <dir>=<entityRef> a monorepo deployable is an
170
+ existing application (repeatable)
171
+ --create-application register even if similar applications exist
172
+ If an application already in the workspace resembles one this
173
+ would register, nothing is written: it reports the matches and
174
+ the command for each choice.
170
175
  --auto-map-application auto-map only when high confidence
171
176
  --non-interactive deprecated (mapping no longer prompts)
172
177
  --batch-size <n> upsert batch size (default: 10)
173
178
  --profile include timing/profile data in JSON output
174
179
  --dry-run preview without writing
180
+ --tool <terraform|opentofu> select the IaC state CLI
175
181
  --json
176
182
  nexarch update-project
177
183
  Re-scan a previously registered project directory, refresh