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.
- package/dist/commands/ingest-infra.js +194 -61
- package/dist/commands/init-agent.js +9 -3
- package/dist/commands/init-project-infra.js +22 -20
- package/dist/commands/init-project.js +382 -25
- package/dist/index.js +7 -1
- package/dist/lib/ansible-detect.js +109 -0
- package/dist/lib/ansible-projection.js +150 -0
- package/dist/lib/iac-detect.js +414 -0
- package/dist/lib/iac-projection.js +231 -0
- package/dist/lib/iac-tool.js +138 -0
- package/dist/lib/skills.js +32 -10
- package/package.json +2 -2
|
@@ -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/
|
|
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
|
|
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
|
|
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
|
|
1520
|
-
//
|
|
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
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
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" : ""}
|
|
1934
|
-
+
|
|
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 =
|
|
2030
|
-
|
|
2031
|
-
|
|
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 === "
|
|
2035
|
-
?
|
|
2036
|
-
:
|
|
2037
|
-
? "
|
|
2038
|
-
:
|
|
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 === "
|
|
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
|
-
--
|
|
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
|