nexarch 0.13.1 → 0.13.3

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.
@@ -610,6 +610,13 @@ function injectInitProjectReportingContract(path) {
610
610
  "unit of estate — a Terraform or OpenTofu root module, each with its own state.",
611
611
  "Until they run, the graph knows the repository exists but nothing it provisions.",
612
612
  "",
613
+ "**One exception: a possible duplicate.** If init-project returns `status: \"review_required\"`",
614
+ "or prints `STOP — POSSIBLE DUPLICATE`, an application already in the workspace looks like",
615
+ "one it would register, and it wrote nothing. Tell the human which one you think matches and",
616
+ "why, wait for their answer, then run the command for their choice yourself",
617
+ "(`decision.commands` in the output) and continue. If it is the same system, enrich the",
618
+ "existing application — nothing needs merging.",
619
+ "",
613
620
  "Stop early only when a step genuinely cannot run — missing cloud credentials, a root",
614
621
  "module that has not been initialised, or an environment you should not touch without",
615
622
  "asking. When that happens, say which step stopped you and why, then ask. Do not",
@@ -1238,6 +1238,110 @@ 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 run the command in `decision.commands` for their choice yourself and carry on. If it is the"
1319
+ + " same system, enrich the existing application: nothing needs merging.";
1320
+ /**
1321
+ * The possible-duplicate warning for the text directive, first thing in it.
1322
+ *
1323
+ * The directive goes to stderr precisely so an agent sees it without parsing
1324
+ * JSON, and it used to carry no duplicate warning at all -- the only one was a
1325
+ * JSON field under a note saying not to pause.
1326
+ */
1327
+ export function possibleDuplicateDirective(matches, isMonorepo) {
1328
+ if (matches.length === 0)
1329
+ return [];
1330
+ return [
1331
+ "",
1332
+ "STOP — POSSIBLE DUPLICATE. Nothing was written.",
1333
+ "Existing applications in this workspace resemble what this scan would register:",
1334
+ ...matches.map((m) => ` ${m.name} (${m.entityRef}) score ${m.score.toFixed(2)} [${m.reasons.join(", ")}]`),
1335
+ isMonorepo
1336
+ ? "Tell the human which deployable you think is the same system, and why. Then re-run with their choice:"
1337
+ : "Tell the human which one you think is the same application, and why. Then re-run with their choice:",
1338
+ isMonorepo
1339
+ ? " same system npx nexarch@latest init-project --dir . --map-deployable <deployable>=<entityRef>"
1340
+ : " same application npx nexarch@latest init-project --dir . --application-ref <entityRef>",
1341
+ " all different npx nexarch@latest init-project --dir . --create-application",
1342
+ "Run the command for their answer yourself. If it is the same system, enrich the existing application — nothing needs merging.",
1343
+ ];
1344
+ }
1241
1345
  /**
1242
1346
  * For each detected name, the entity_refs of what declares it.
1243
1347
  *
@@ -1440,6 +1544,42 @@ export async function initProject(args) {
1440
1544
  seenSubSlugs.add(dirSlug);
1441
1545
  sp.externalKey = `${sp.entityType}:${projectDirSlug}_${dirSlug}`;
1442
1546
  }
1547
+ // Refuse bad mappings before anything is written, naming every problem.
1548
+ const deployableMappings = parseDeployableMappings(args);
1549
+ if (deployableMappings.length > 0) {
1550
+ const mappingErrors = isMonorepo
1551
+ ? applyDeployableMappings(subPackages, deployableMappings)
1552
+ : ["--map-deployable applies to a repository with several deployables. For a single-package repository use --application-ref."];
1553
+ if (mappingErrors.length > 0)
1554
+ throw new Error(mappingErrors.join("\n"));
1555
+ }
1556
+ // A merge leaves the losing application's ref behind as an alias of the
1557
+ // survivor. Without honouring it, the next scan of the repository would
1558
+ // re-create the application a human had just merged away. Looked up with
1559
+ // recordUnresolved: false -- an unmapped "application:acme_web" is not a
1560
+ // technology to put in front of catalogue reviewers.
1561
+ if (projectConstruct && isMonorepo && !forceCreateApplication) {
1562
+ const unmapped = subPackages.filter((sp) => isApplicationLikeEntityType(sp.entityType) && !sp.mappedFrom);
1563
+ if (unmapped.length > 0) {
1564
+ try {
1565
+ const aliasRaw = await callMcpProfiled("nexarch_resolve_reference", { names: unmapped.map((sp) => sp.externalKey), companyId: creds.companyId, recordUnresolved: false }, { phase: "deployable.alias" });
1566
+ for (const hit of parseToolText(aliasRaw).results ?? []) {
1567
+ const sp = unmapped.find((candidate) => candidate.externalKey === hit.input);
1568
+ const ref = hit.canonicalExternalRef;
1569
+ if (!sp || !hit.resolved || hit.entityTypeCode !== "application" || !ref?.startsWith("application:") || ref === sp.externalKey)
1570
+ continue;
1571
+ sp.mappedFrom = sp.externalKey;
1572
+ sp.externalKey = ref;
1573
+ sp.mappedBy = "alias";
1574
+ if (!asJson)
1575
+ console.log(` Mapped : ${sp.relativePath} → ${ref} (${hit.canonicalName ?? "existing application"}), from an earlier merge`);
1576
+ }
1577
+ }
1578
+ catch {
1579
+ // Best effort. If the lookup fails, the duplicate check below still runs.
1580
+ }
1581
+ }
1582
+ }
1443
1583
  if (!asJson) {
1444
1584
  console.log(` Project : ${displayName} (${entityTypeOverride})`);
1445
1585
  console.log(` Packages: ${packageJsonCount} package.json file(s)`);
@@ -1542,6 +1682,15 @@ export async function initProject(args) {
1542
1682
  const sourceProvider = detectedRepo?.provider ?? "unknown";
1543
1683
  const declaredHomepage = readDeclaredHomepage(dir);
1544
1684
  let similarApplications = [];
1685
+ // Set when the scan must stop and ask instead of registering anything.
1686
+ let stopForReview = null;
1687
+ // Names of applications already in the workspace, by ref, for mapped deployables.
1688
+ const existingApplicationNames = new Map();
1689
+ // Refs of those that are still proposals: a mapped deployable's leftover
1690
+ // duplicate from an earlier scan is folded away only if nobody reviewed it.
1691
+ const proposedApplicationRefs = new Set();
1692
+ // What happened to each leftover duplicate, for the output.
1693
+ const foldedDuplicates = [];
1545
1694
  if (projectConstruct && isMonorepo && (applicationRefOverride || forceCreateApplication) && !asJson) {
1546
1695
  console.log("\nNote: monorepo detected — no root application is created (ADR 8a); --application-ref/--create-application apply only to single-package repositories.");
1547
1696
  }
@@ -1575,16 +1724,18 @@ export async function initProject(args) {
1575
1724
  if (!applicationRefOverride && projectExternalKey === `${entityTypeOverride}:${projectSlug}`) {
1576
1725
  const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500 });
1577
1726
  const appsData = parseToolText(appsRaw);
1578
- const apps = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
1727
+ const listed = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
1728
+ // This repository's own application, on a re-run, matches its own name
1729
+ // exactly. Now that a match stops the scan, that would stop every re-run.
1730
+ const alreadyRegistered = listed.some((e) => (e.entityRef ?? e.externalKey) === projectExternalKey);
1731
+ const apps = listed.filter((e) => (e.entityRef ?? e.externalKey) !== projectExternalKey);
1579
1732
  if (apps.length > 0) {
1580
1733
  const matches = apps
1581
1734
  .map((a) => scoreApplicationCandidate(a, displayName, repoUrl, declaredHomepage))
1582
1735
  .filter((m) => Boolean(m))
1583
1736
  .sort((a, b) => b.score - a.score);
1584
- // Captured for the JSON output. Until this existed the branch below was
1585
- // guarded by `!asJson`, so an agent -- which always runs --json -- got a
1586
- // result with no hint that a similar application already existed, and
1587
- // reported the new proposal as if nothing resembled it.
1737
+ // Captured for the JSON output, not only printed: an agent always runs
1738
+ // --json, and a match it never sees is a match nobody acts on.
1588
1739
  similarApplications = matches.slice(0, 5);
1589
1740
  const suggested = matches.length > 0 ? matches[0] : null;
1590
1741
  const highConfidence = suggested && suggested.score >= 0.85;
@@ -1598,15 +1749,19 @@ export async function initProject(args) {
1598
1749
  // remains available via --application-ref and --auto-map-application.
1599
1750
  if (autoMapApplication && highConfidence) {
1600
1751
  projectExternalKey = suggested.entityRef;
1752
+ similarApplications = [];
1601
1753
  if (!asJson)
1602
1754
  console.log(`\nAuto-mapped to existing application: ${suggested.name} (${projectExternalKey})`);
1603
1755
  }
1604
- else if (matches.length > 0 && !asJson) {
1605
- console.log("\nSimilar existing applications found (registering a new proposed application anyway — the reviewer sees these again at activation):");
1606
- for (const m of matches.slice(0, 5)) {
1607
- console.log(` - ${m.name} (${m.entityRef}) score=${m.score.toFixed(2)} [${m.reasons.join(", ")}]`);
1608
- }
1609
- console.log(" To map to one instead, re-run with: --application-ref <entityRef>");
1756
+ else {
1757
+ const gate = registrationGate({
1758
+ similar: similarApplications,
1759
+ newApplicationRefs: alreadyRegistered ? [] : [projectExternalKey],
1760
+ createAnyway: false,
1761
+ });
1762
+ similarApplications = gate.similar;
1763
+ if (gate.stop)
1764
+ stopForReview = { newApplications: [{ deployable: basename(dir), entityRef: projectExternalKey }] };
1610
1765
  }
1611
1766
  }
1612
1767
  }
@@ -1629,18 +1784,72 @@ export async function initProject(args) {
1629
1784
  // reports the repo as a duplicate of itself.
1630
1785
  const ownRefs = new Set(subPackages.map((sp) => sp.externalKey));
1631
1786
  const appsRaw = await callMcpProfiled("nexarch_list_entities", { entityTypeCode: "application", status: "active", limit: 500, companyId: creds.companyId }, { entityTypeCode: "application", limit: 500, phase: "monorepo.similar" });
1632
- similarApplications = similarApplicationsForRepository(parseToolText(appsRaw).entities ?? [], {
1633
- repositoryName: displayName,
1634
- repoUrl,
1635
- homepageUrl: declaredHomepage,
1636
- ownRefs,
1787
+ const listed = parseToolText(appsRaw).entities ?? [];
1788
+ for (const entity of listed) {
1789
+ const ref = entity.entityRef ?? entity.externalKey;
1790
+ if (!ref)
1791
+ continue;
1792
+ existingApplicationNames.set(ref, entity.name);
1793
+ if (String(entity.attributes?.workflow_state ?? "").toLowerCase() === "proposed")
1794
+ proposedApplicationRefs.add(ref);
1795
+ }
1796
+ // A mapped deployable writes no stub of its own, so a mapping to an
1797
+ // application that is not there would leave its relationships pointing at
1798
+ // nothing. Refuse before writing.
1799
+ const badTargets = subPackages.filter((sp) => sp.mappedBy === "flag" && !existingApplicationNames.has(sp.externalKey));
1800
+ if (badTargets.length > 0) {
1801
+ throw new Error(badTargets
1802
+ .map((sp) => `--map-deployable: ${sp.externalKey} is not an active application in this workspace (mapping ${sp.relativePath}).`)
1803
+ .join("\n"));
1804
+ }
1805
+ const newDeployables = subPackages.filter((sp) => isApplicationLikeEntityType(sp.entityType) && !existingApplicationNames.has(sp.externalKey));
1806
+ const gate = registrationGate({
1807
+ similar: similarApplicationsForRepository(listed, { repositoryName: displayName, repoUrl, homepageUrl: declaredHomepage, ownRefs }),
1808
+ newApplicationRefs: newDeployables.map((sp) => sp.externalKey),
1809
+ createAnyway: false,
1637
1810
  });
1638
- if (similarApplications.length > 0 && !asJson) {
1639
- console.log("\nExisting applications resemble this repository — one of its deployables may already be registered:");
1640
- for (const match of similarApplications) {
1641
- console.log(` - ${match.name} (${match.entityRef}) score=${match.score.toFixed(2)} [${match.reasons.join(", ")}]`);
1811
+ similarApplications = gate.similar;
1812
+ if (gate.stop) {
1813
+ stopForReview = {
1814
+ newApplications: newDeployables.map((sp) => ({ deployable: basename(sp.relativePath), entityRef: sp.externalKey })),
1815
+ };
1816
+ }
1817
+ }
1818
+ // Paul's decision: a possible duplicate stops the scan before it writes
1819
+ // anything. Resolution has already run (it only reads, and logs names not in
1820
+ // the catalogue); nothing below this point has.
1821
+ if (stopForReview) {
1822
+ const directive = possibleDuplicateDirective(similarApplications, isMonorepo);
1823
+ const commands = isMonorepo
1824
+ ? {
1825
+ sameSystem: "npx nexarch@latest init-project --dir . --map-deployable <deployable>=<entityRef>",
1826
+ allDifferent: "npx nexarch@latest init-project --dir . --create-application",
1642
1827
  }
1828
+ : {
1829
+ sameApplication: "npx nexarch@latest init-project --dir . --application-ref <entityRef>",
1830
+ allDifferent: "npx nexarch@latest init-project --dir . --create-application",
1831
+ };
1832
+ if (asJson) {
1833
+ process.stdout.write(`${JSON.stringify({
1834
+ ok: true,
1835
+ status: "review_required",
1836
+ wrote: false,
1837
+ project: projectConstruct
1838
+ ? { name: projectDirName, externalKey: projectEntityKey, entityType: "project", subtype: isMonorepo ? "project_monorepo" : "project_repository" }
1839
+ : { name: displayName, externalKey: projectExternalKey, entityType: entityTypeOverride },
1840
+ similarApplications,
1841
+ newApplications: stopForReview.newApplications,
1842
+ decision: { note: REVIEW_REQUIRED_NOTE, commands },
1843
+ }, null, 2)}\n`);
1844
+ process.stderr.write(`${directive.join("\n")}\n`);
1845
+ }
1846
+ else {
1847
+ console.log(directive.join("\n"));
1848
+ console.log("\nWould have registered:");
1849
+ for (const app of stopForReview.newApplications)
1850
+ console.log(` ${app.deployable} → ${app.entityRef}`);
1643
1851
  }
1852
+ return;
1644
1853
  }
1645
1854
  logProgress(projectConstruct ? "project.target" : "application.target", projectConstruct ? (isMonorepo ? projectEntityKey : `${projectEntityKey} + ${projectExternalKey}`) : projectExternalKey);
1646
1855
  // In refresh mode, snapshot the current graph state for this project before writing,
@@ -1760,6 +1969,10 @@ export async function initProject(args) {
1760
1969
  if (seenSubKeys.has(sp.externalKey))
1761
1970
  continue;
1762
1971
  seenSubKeys.add(sp.externalKey);
1972
+ // The application already exists and a human may have named and described
1973
+ // it. Its relationships below still use its ref.
1974
+ if (sp.mappedFrom)
1975
+ continue;
1763
1976
  const dirDisplayName = basename(sp.relativePath);
1764
1977
  entities.push({
1765
1978
  externalKey: sp.externalKey,
@@ -1969,6 +2182,61 @@ export async function initProject(args) {
1969
2182
  // ability to resolve via tier 2; the scan itself already succeeded.
1970
2183
  }
1971
2184
  }
2185
+ // "There should be nothing to merge" (Paul, 16 September). When the human has
2186
+ // confirmed a deployable is an existing application, an earlier scan may
2187
+ // already have registered that deployable as a proposal of its own -- the
2188
+ // 0.13.1 behaviour, or any run before the duplicate check stopped the scan.
2189
+ // Fold it into the existing application here, so the agent carries on
2190
+ // enriching the real one and nobody has to press Merge. Only unreviewed
2191
+ // proposals: the gateway refuses anything a human has activated.
2192
+ for (const sp of subPackages) {
2193
+ if (sp.mappedBy !== "flag" || !sp.mappedFrom || !proposedApplicationRefs.has(sp.mappedFrom))
2194
+ continue;
2195
+ try {
2196
+ const raw = await callMcpTool("nexarch_merge_proposed_application", {
2197
+ proposedApplicationRef: sp.mappedFrom,
2198
+ intoApplicationRef: sp.externalKey,
2199
+ reason: `The human confirmed the ${sp.relativePath} deployable is ${existingApplicationNames.get(sp.externalKey) ?? sp.externalKey}.`,
2200
+ companyId: creds.companyId,
2201
+ }, { companyId: creds.companyId });
2202
+ const result = parseToolText(raw);
2203
+ foldedDuplicates.push({
2204
+ proposedApplicationRef: sp.mappedFrom,
2205
+ intoApplicationRef: sp.externalKey,
2206
+ folded: true,
2207
+ detail: `folded in; ${result.relationshipsRepointed ?? 0} relationship(s) moved to ${sp.externalKey}`,
2208
+ });
2209
+ }
2210
+ catch (error) {
2211
+ // An older gateway without the tool, or a credential without the scope.
2212
+ // Say so plainly: the duplicate is still there and the human should know.
2213
+ foldedDuplicates.push({
2214
+ proposedApplicationRef: sp.mappedFrom,
2215
+ intoApplicationRef: sp.externalKey,
2216
+ folded: false,
2217
+ detail: error instanceof Error ? error.message : String(error),
2218
+ });
2219
+ }
2220
+ }
2221
+ // Remember an explicit --map-deployable, so the next scan does not ask again.
2222
+ // A merge registers the same alias; this is the same decision made up front.
2223
+ for (const sp of subPackages) {
2224
+ if (sp.mappedBy !== "flag" || !sp.mappedFrom)
2225
+ continue;
2226
+ try {
2227
+ await callMcpTool("nexarch_register_alias", {
2228
+ alias: sp.mappedFrom,
2229
+ canonicalExternalKey: sp.externalKey,
2230
+ canonicalName: existingApplicationNames.get(sp.externalKey) ?? sp.externalKey,
2231
+ entityTypeCode: "application",
2232
+ description: `Registered by init-project: the ${sp.relativePath} deployable is this application.`,
2233
+ companyId: creds.companyId,
2234
+ }, { companyId: creds.companyId });
2235
+ }
2236
+ catch {
2237
+ // Non-fatal: without it the next scan asks again, which is safe.
2238
+ }
2239
+ }
1972
2240
  // Upsert relationships (chunked)
1973
2241
  let relsResult = null;
1974
2242
  if (relationships.length > 0) {
@@ -2027,8 +2295,7 @@ export async function initProject(args) {
2027
2295
  if (similarApplications.length > 0 && isMonorepo) {
2028
2296
  // --application-ref does not apply to a monorepo (the CLI says so above),
2029
2297
  // so the single-repo remedy would hand the agent a command that does
2030
- // nothing. The resolution here is a merge, which a human does on the
2031
- // application page.
2298
+ // nothing. The resolution is --map-deployable, which the agent runs.
2032
2299
  pendingSteps.push({
2033
2300
  step: stepNum++,
2034
2301
  action: "review_similar_applications",
@@ -2036,8 +2303,9 @@ export async function initProject(args) {
2036
2303
  + ` workspace resemble${similarApplications.length === 1 ? "s" : ""} this repository: ${matchList}.`
2037
2304
  + " One of the deployables just registered may be the same system — for example a web app registered"
2038
2305
  + " earlier from its public website. Tell the human which deployable you think matches and why, before"
2039
- + " enriching anything. The new deployables are PROPOSED, so nothing is broken yet; if one is a"
2040
- + " duplicate, the human merges the two with \"Merge with…\" on the application's page.",
2306
+ + " enriching anything. If the human confirms one is the same system, re-run with"
2307
+ + " --map-deployable <deployable>=<entityRef> yourself and enrich the existing application: the scan"
2308
+ + " folds this proposal into it, so there is nothing for anyone to merge.",
2041
2309
  notes: [
2042
2310
  "Do not report the registration as complete without mentioning these.",
2043
2311
  "The match is on the repository, not a specific deployable: say which one you believe it is, and why.",
@@ -2145,16 +2413,21 @@ export async function initProject(args) {
2145
2413
  });
2146
2414
  const preservedEntities = entitiesResult.preserved ?? [];
2147
2415
  const nothingWritten = (entitiesResult.summary?.succeeded ?? 0) === 0;
2148
- const status = refreshMode && nothingWritten && preservedEntities.length > 0
2149
- ? "review_enrichment"
2150
- : "enrichment_required";
2416
+ const status = enrichmentStatusFor({
2417
+ similarApplicationCount: similarApplications.length,
2418
+ refreshMode,
2419
+ nothingWritten,
2420
+ preservedEntityCount: preservedEntities.length,
2421
+ });
2151
2422
  return {
2152
2423
  status,
2153
- note: status === "review_enrichment"
2154
- ? "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."
2155
- : refreshMode
2156
- ? "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."
2157
- : "Execute ALL pendingSteps before reporting the project as registered. Do not pause or ask for confirmation between steps.",
2424
+ note: status === "review_required"
2425
+ ? REVIEW_REQUIRED_NOTE
2426
+ : status === "review_enrichment"
2427
+ ? "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."
2428
+ : refreshMode
2429
+ ? "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."
2430
+ : "Execute ALL pendingSteps before reporting the project as registered. Do not pause or ask for confirmation between steps.",
2158
2431
  ...(refreshMode && preservedEntities.length > 0
2159
2432
  ? {
2160
2433
  preservedEntities: {
@@ -2276,6 +2549,7 @@ export async function initProject(args) {
2276
2549
  lines.push("init-project wrote a skeleton. The steps below complete the registration.");
2277
2550
  lines.push("Until they are done, this project is registered as a skeleton only.");
2278
2551
  }
2552
+ lines.push(...possibleDuplicateDirective(similarApplications, isMonorepo));
2279
2553
  lines.push("");
2280
2554
  lines.push(`PROJECT : ${projectConstruct ? `${projectEntityKey} (${isMonorepo ? "monorepo" : "repository"})` : projectExternalKey}`);
2281
2555
  lines.push(`DIR : ${dir}`);
@@ -2300,6 +2574,13 @@ export async function initProject(args) {
2300
2574
  const conf = subPackageConfidence(sp);
2301
2575
  lines.push(` ${sp.relativePath}`);
2302
2576
  lines.push(` key : ${sp.externalKey}`);
2577
+ if (sp.mappedFrom) {
2578
+ // "Confirm type, then update" is right for a new skeleton and wrong
2579
+ // here: this is an application a human already registered and named.
2580
+ lines.push(` MAPPED : this deployable is the existing application ${sp.externalKey}${sp.mappedBy === "alias" ? " (from an earlier merge)" : ""}.`);
2581
+ lines.push(" It is this deployable's application: enrich it — name, description, type and");
2582
+ lines.push(" functions — from the code, as you would a new one.");
2583
+ }
2303
2584
  lines.push(` inferred : ${sp.entityType} / ${sp.subtype} (confidence ${conf.toFixed(2)})`);
2304
2585
  const unresolvedDeps = sp.depSpecs.map((d) => d.name).filter((d) => !resolvedByInput.has(d));
2305
2586
  if (unresolvedDeps.length > 0) {
@@ -2432,6 +2713,7 @@ export async function initProject(args) {
2432
2713
  resolved: resolvedItems.length,
2433
2714
  unresolved: unresolvedItems.length,
2434
2715
  similarApplications,
2716
+ ...(foldedDuplicates.length > 0 ? { foldedDuplicates } : {}),
2435
2717
  entityErrors: entitiesResult.errors ?? [],
2436
2718
  relationshipErrors: relsResult?.errors ?? [],
2437
2719
  enrichmentRequired,
@@ -2460,7 +2742,11 @@ export async function initProject(args) {
2460
2742
  const preservedCount = output.entities.preserved ?? 0;
2461
2743
  console.log(` Entities : ${output.entities.succeeded ?? 0} written, ${preservedCount > 0 ? `${preservedCount} preserved (enriched), ` : ""}${output.entities.failed ?? 0} failed`);
2462
2744
  console.log(` Relationships: ${output.relationships.succeeded ?? 0} written`);
2463
- console.log(` Status : ${output.status === "review_enrichment" ? "all enriched; review preserved entities" : "skeleton created; enrichment pending"}`);
2745
+ console.log(` Status : ${output.status === "review_required"
2746
+ ? "possible duplicate; ask before enriching"
2747
+ : output.status === "review_enrichment"
2748
+ ? "all enriched; review preserved entities"
2749
+ : "skeleton created; enrichment pending"}`);
2464
2750
  if (output.metrics.relationshipsSkippedAsDuplicate > 0) {
2465
2751
  console.log(` Deduped rels : ${output.metrics.relationshipsSkippedAsDuplicate} skipped as duplicates before upsert`);
2466
2752
  }
package/dist/index.js CHANGED
@@ -166,7 +166,12 @@ 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",