nexarch 0.12.44 → 0.13.1
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/feedback.js +51 -0
- package/dist/commands/governance-summary.js +27 -9
- package/dist/commands/ingest-infra.js +194 -61
- package/dist/commands/init-agent.js +139 -62
- package/dist/commands/init-project-infra.js +22 -20
- package/dist/commands/init-project.js +207 -13
- package/dist/commands/policy-audit-results.js +18 -10
- package/dist/commands/policy-audit-submit.js +27 -17
- package/dist/commands/policy-controls.js +2 -1
- package/dist/commands/verify-trust.js +1 -12
- package/dist/index.js +10 -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 +46 -0
- package/dist/lib/trust.js +11 -0
- 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";
|
|
@@ -1165,7 +1165,36 @@ function extractHost(input) {
|
|
|
1165
1165
|
return null;
|
|
1166
1166
|
}
|
|
1167
1167
|
}
|
|
1168
|
-
|
|
1168
|
+
/**
|
|
1169
|
+
* Item 6: agent-authored descriptions read like commit messages — framework
|
|
1170
|
+
* names, folder layout, build tooling. The reader is an architect, a new joiner
|
|
1171
|
+
* or someone deciding whether to reuse the thing, and none of them work on this
|
|
1172
|
+
* code. Stated once here and referenced from every step that asks for a
|
|
1173
|
+
* description, because a rule stated in one of five steps is not a rule the
|
|
1174
|
+
* model reliably follows.
|
|
1175
|
+
*/
|
|
1176
|
+
export const DESCRIPTION_GUIDANCE = "Write descriptions for someone who does not work on this code — an architect, a new joiner, or"
|
|
1177
|
+
+ " someone deciding whether to reuse it. One or two sentences: what it does for the business, who"
|
|
1178
|
+
+ " uses it, and what it owns. Name a technology only when the technology is the point (a Postgres"
|
|
1179
|
+
+ " connection pooler), not as a substitute for purpose."
|
|
1180
|
+
+ ' Good: "Takes card payments for the online store and is the system of record for refunds."'
|
|
1181
|
+
+ ' Poor: "Next.js 16 app with Drizzle ORM and a REST API under /api."';
|
|
1182
|
+
/** The project's declared homepage, used to tell a repository scan and a website
|
|
1183
|
+
* onboarding run that they are describing the same application. */
|
|
1184
|
+
export function readDeclaredHomepage(dir) {
|
|
1185
|
+
try {
|
|
1186
|
+
const pkgPath = join(dir, "package.json");
|
|
1187
|
+
if (!existsSync(pkgPath))
|
|
1188
|
+
return null;
|
|
1189
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
1190
|
+
const homepage = typeof pkg.homepage === "string" ? pkg.homepage.trim() : "";
|
|
1191
|
+
return homepage ? homepage : null;
|
|
1192
|
+
}
|
|
1193
|
+
catch {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
export function scoreApplicationCandidate(app, projectName, repoUrl, homepageUrl = null) {
|
|
1169
1198
|
const entityRef = app.entityRef ?? app.externalKey ?? null;
|
|
1170
1199
|
if (!entityRef)
|
|
1171
1200
|
return null;
|
|
@@ -1186,17 +1215,86 @@ export function scoreApplicationCandidate(app, projectName, repoUrl) {
|
|
|
1186
1215
|
score += 0.25;
|
|
1187
1216
|
reasons.push("ref_matches_project");
|
|
1188
1217
|
}
|
|
1189
|
-
const repoHost = extractHost(repoUrl);
|
|
1190
1218
|
const appWebsite = typeof app.attributes?.website_url === "string" ? app.attributes.website_url : null;
|
|
1191
1219
|
const appWebsiteHost = extractHost(appWebsite);
|
|
1192
|
-
|
|
1220
|
+
// The project's declared homepage, not its repository URL. Comparing the repo
|
|
1221
|
+
// host against a website host compares "gitlab.com" with "nexarch.ai" and can
|
|
1222
|
+
// never match, which is why an application registered by website onboarding
|
|
1223
|
+
// and the same application registered by a repository scan scored as
|
|
1224
|
+
// unrelated and both ended up in the graph.
|
|
1225
|
+
const homepageHost = extractHost(homepageUrl);
|
|
1226
|
+
if (homepageHost && appWebsiteHost && homepageHost === appWebsiteHost) {
|
|
1193
1227
|
score += 0.5;
|
|
1194
1228
|
reasons.push("website_host_exact");
|
|
1195
1229
|
}
|
|
1230
|
+
// Kept as a weak signal: a self-hosted estate can legitimately register a
|
|
1231
|
+
// repository host as an application's website.
|
|
1232
|
+
const repoHost = extractHost(repoUrl);
|
|
1233
|
+
if (repoHost && appWebsiteHost && repoHost === appWebsiteHost) {
|
|
1234
|
+
score += 0.15;
|
|
1235
|
+
reasons.push("repo_host_matches_website");
|
|
1236
|
+
}
|
|
1196
1237
|
if (score <= 0)
|
|
1197
1238
|
return null;
|
|
1198
1239
|
return { entityRef, name: app.name, score: Math.min(1, score), reasons };
|
|
1199
1240
|
}
|
|
1241
|
+
/**
|
|
1242
|
+
* For each detected name, the entity_refs of what declares it.
|
|
1243
|
+
*
|
|
1244
|
+
* Sent with name resolution so that a name not yet in the catalogue is recorded
|
|
1245
|
+
* against the thing that depends on it. Without it, adding `elkjs` to the
|
|
1246
|
+
* catalogue later added it to every workspace that had seen it -- attached to
|
|
1247
|
+
* nothing, because the sighting only knew the repository, and on a first scan
|
|
1248
|
+
* not even that.
|
|
1249
|
+
*
|
|
1250
|
+
* Only application-like packages are dependents: the re-resolve job writes the
|
|
1251
|
+
* edge directly, and a shared library depending on a technology is not an edge
|
|
1252
|
+
* the ontology draws. A monorepo's root manifest is repository tooling, not a
|
|
1253
|
+
* deployable, so it has no dependent; a single-package repository's root is
|
|
1254
|
+
* the application.
|
|
1255
|
+
*/
|
|
1256
|
+
export function dependentRefsForNames(params) {
|
|
1257
|
+
const refs = new Map();
|
|
1258
|
+
const declare = (name, ref) => {
|
|
1259
|
+
if (!refs.has(name))
|
|
1260
|
+
refs.set(name, new Set());
|
|
1261
|
+
refs.get(name).add(ref);
|
|
1262
|
+
};
|
|
1263
|
+
for (const sp of params.subPackages) {
|
|
1264
|
+
if (!isApplicationLikeEntityType(sp.entityType) || !sp.externalKey)
|
|
1265
|
+
continue;
|
|
1266
|
+
for (const dep of sp.depSpecs)
|
|
1267
|
+
declare(dep.name, sp.externalKey);
|
|
1268
|
+
}
|
|
1269
|
+
if (params.rootDependentRef) {
|
|
1270
|
+
for (const name of params.rootDepNames)
|
|
1271
|
+
declare(name, params.rootDependentRef);
|
|
1272
|
+
}
|
|
1273
|
+
return Object.fromEntries([...refs].map(([name, set]) => [name, [...set].sort()]));
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Existing applications that resemble a repository, for a monorepo.
|
|
1277
|
+
*
|
|
1278
|
+
* Scored on the repository, not on each deployable: per-deployable name
|
|
1279
|
+
* scoring reports the wrong thing. A CLI whose npm name is the product name
|
|
1280
|
+
* scores as a duplicate of the product's website, and short names like "web"
|
|
1281
|
+
* match anything they happen to be a substring of.
|
|
1282
|
+
*
|
|
1283
|
+
* `ownRefs` are this repository's own deployables. They exist on a re-run and
|
|
1284
|
+
* every one contains the repository name, so without excluding them a second
|
|
1285
|
+
* scan reports the repository as a duplicate of itself.
|
|
1286
|
+
*/
|
|
1287
|
+
export function similarApplicationsForRepository(apps, params) {
|
|
1288
|
+
return apps
|
|
1289
|
+
.filter((app) => {
|
|
1290
|
+
const ref = app.entityRef ?? app.externalKey;
|
|
1291
|
+
return Boolean(ref) && !params.ownRefs.has(ref);
|
|
1292
|
+
})
|
|
1293
|
+
.map((app) => scoreApplicationCandidate(app, params.repositoryName, params.repoUrl, params.homepageUrl))
|
|
1294
|
+
.filter((match) => Boolean(match))
|
|
1295
|
+
.sort((a, b) => b.score - a.score)
|
|
1296
|
+
.slice(0, 5);
|
|
1297
|
+
}
|
|
1200
1298
|
// ─── Main command ─────────────────────────────────────────────────────────────
|
|
1201
1299
|
export async function initProject(args) {
|
|
1202
1300
|
const asJson = parseFlag(args, "--json");
|
|
@@ -1266,6 +1364,7 @@ export async function initProject(args) {
|
|
|
1266
1364
|
nonInteractive: parseFlag(args, "--non-interactive"),
|
|
1267
1365
|
skipIngest: parseFlag(args, "--no-ingest"),
|
|
1268
1366
|
dryRun,
|
|
1367
|
+
tool: parseOptionValue(args, "--tool"),
|
|
1269
1368
|
}, infrastructure);
|
|
1270
1369
|
}
|
|
1271
1370
|
return;
|
|
@@ -1284,6 +1383,7 @@ export async function initProject(args) {
|
|
|
1284
1383
|
nonInteractive: parseFlag(args, "--non-interactive"),
|
|
1285
1384
|
skipIngest: parseFlag(args, "--no-ingest"),
|
|
1286
1385
|
dryRun,
|
|
1386
|
+
tool: parseOptionValue(args, "--tool"),
|
|
1287
1387
|
}, infrastructure);
|
|
1288
1388
|
return;
|
|
1289
1389
|
}
|
|
@@ -1358,11 +1458,17 @@ export async function initProject(args) {
|
|
|
1358
1458
|
console.log("\nResolving against reference library…");
|
|
1359
1459
|
const allResolveResults = [];
|
|
1360
1460
|
const BATCH_SIZE = 200;
|
|
1461
|
+
const dependentRefsByName = dependentRefsForNames({
|
|
1462
|
+
subPackages,
|
|
1463
|
+
rootDepNames,
|
|
1464
|
+
rootDependentRef: isMonorepo ? null : projectExternalKey,
|
|
1465
|
+
});
|
|
1361
1466
|
logProgress("resolve.start", `count=${detectedNames.length}, batchSize=${BATCH_SIZE}`);
|
|
1362
1467
|
for (let i = 0; i < detectedNames.length; i += BATCH_SIZE) {
|
|
1363
1468
|
const batch = detectedNames.slice(i, i + BATCH_SIZE);
|
|
1364
1469
|
logProgress("resolve.batch", `${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(detectedNames.length / BATCH_SIZE)} size=${batch.length}`);
|
|
1365
|
-
const
|
|
1470
|
+
const dependentRefs = Object.fromEntries(batch.filter((name) => dependentRefsByName[name]?.length).map((name) => [name, dependentRefsByName[name]]));
|
|
1471
|
+
const raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId, projectRef: projectEntityKey, dependentRefs }, { batchSize: batch.length });
|
|
1366
1472
|
const data = parseToolText(raw);
|
|
1367
1473
|
allResolveResults.push(...data.results);
|
|
1368
1474
|
}
|
|
@@ -1371,7 +1477,7 @@ export async function initProject(args) {
|
|
|
1371
1477
|
const unresolvedItems = allResolveResults.filter((r) => !r.resolved);
|
|
1372
1478
|
if (!asJson) {
|
|
1373
1479
|
console.log(` Resolved : ${resolvedItems.length}/${detectedNames.length}`);
|
|
1374
|
-
console.log(` Candidates: ${unresolvedItems.length}
|
|
1480
|
+
console.log(` Candidates: ${unresolvedItems.length} detected dependencies not yet in the Nexarch catalogue`);
|
|
1375
1481
|
}
|
|
1376
1482
|
if (dryRun) {
|
|
1377
1483
|
const output = {
|
|
@@ -1434,6 +1540,8 @@ export async function initProject(args) {
|
|
|
1434
1540
|
const repoUrl = (repoUrlOverride ? normalizeRepoUrl(repoUrlOverride) ?? repoUrlOverride : null) ?? detectedRepo?.url ?? null;
|
|
1435
1541
|
const sourceVcsType = detectedRepo?.vcsType ?? "unknown";
|
|
1436
1542
|
const sourceProvider = detectedRepo?.provider ?? "unknown";
|
|
1543
|
+
const declaredHomepage = readDeclaredHomepage(dir);
|
|
1544
|
+
let similarApplications = [];
|
|
1437
1545
|
if (projectConstruct && isMonorepo && (applicationRefOverride || forceCreateApplication) && !asJson) {
|
|
1438
1546
|
console.log("\nNote: monorepo detected — no root application is created (ADR 8a); --application-ref/--create-application apply only to single-package repositories.");
|
|
1439
1547
|
}
|
|
@@ -1470,9 +1578,14 @@ export async function initProject(args) {
|
|
|
1470
1578
|
const apps = (appsData.entities ?? []).filter((e) => (e.entityRef ?? e.externalKey));
|
|
1471
1579
|
if (apps.length > 0) {
|
|
1472
1580
|
const matches = apps
|
|
1473
|
-
.map((a) => scoreApplicationCandidate(a, displayName, repoUrl))
|
|
1581
|
+
.map((a) => scoreApplicationCandidate(a, displayName, repoUrl, declaredHomepage))
|
|
1474
1582
|
.filter((m) => Boolean(m))
|
|
1475
1583
|
.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.
|
|
1588
|
+
similarApplications = matches.slice(0, 5);
|
|
1476
1589
|
const suggested = matches.length > 0 ? matches[0] : null;
|
|
1477
1590
|
const highConfidence = suggested && suggested.score >= 0.85;
|
|
1478
1591
|
// ADR 8d: the interactive "Choose target application" prompt is gone.
|
|
@@ -1498,6 +1611,37 @@ export async function initProject(args) {
|
|
|
1498
1611
|
}
|
|
1499
1612
|
}
|
|
1500
1613
|
}
|
|
1614
|
+
// The branch above is gated on !isMonorepo, so for a monorepo -- including
|
|
1615
|
+
// this repository -- the duplicate check never ran at all. Paul's 16
|
|
1616
|
+
// September test registered web, mcp-gateway and nexarch-cli into a
|
|
1617
|
+
// workspace that already held "nexarch.ai website" from onboarding, and
|
|
1618
|
+
// nothing was flagged: item 17's homepage scoring simply never executed.
|
|
1619
|
+
//
|
|
1620
|
+
// Scored at REPOSITORY level, not per deployable. Name scoring per deployable
|
|
1621
|
+
// is worse than silence here: the CLI's npm name is literally "nexarch", so it
|
|
1622
|
+
// scores 0.5 against the website and would be reported as its duplicate, and
|
|
1623
|
+
// "web" matches only because it is a substring of "website". The repository
|
|
1624
|
+
// name is the honest signal, and which deployable (if any) is the same system
|
|
1625
|
+
// is a judgement for a human -- the step below says so.
|
|
1626
|
+
if (projectConstruct && isMonorepo && !forceCreateApplication) {
|
|
1627
|
+
// This repository's own deployables exist on a re-run, and every one of
|
|
1628
|
+
// them contains the repository name. Without excluding them, a second run
|
|
1629
|
+
// reports the repo as a duplicate of itself.
|
|
1630
|
+
const ownRefs = new Set(subPackages.map((sp) => sp.externalKey));
|
|
1631
|
+
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,
|
|
1637
|
+
});
|
|
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(", ")}]`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1501
1645
|
logProgress(projectConstruct ? "project.target" : "application.target", projectConstruct ? (isMonorepo ? projectEntityKey : `${projectEntityKey} + ${projectExternalKey}`) : projectExternalKey);
|
|
1502
1646
|
// In refresh mode, snapshot the current graph state for this project before writing,
|
|
1503
1647
|
// so we can diff what changed and surface stale relationships to the agent.
|
|
@@ -1873,11 +2017,57 @@ export async function initProject(args) {
|
|
|
1873
2017
|
// so agents using --json receive the same mandatory instructions as text-mode agents.
|
|
1874
2018
|
const pendingSteps = [];
|
|
1875
2019
|
let stepNum = 1;
|
|
2020
|
+
// First, because it is the one step that can make the rest unnecessary: if
|
|
2021
|
+
// this application already exists under another name, everything after this
|
|
2022
|
+
// enriches a duplicate. The scan has always computed these matches; until
|
|
2023
|
+
// now it printed them only in human mode, so the agent never saw them.
|
|
2024
|
+
const matchList = similarApplications
|
|
2025
|
+
.map((m) => `${m.name} (${m.entityRef}, score ${m.score.toFixed(2)}, ${m.reasons.join(", ")})`)
|
|
2026
|
+
.join("; ");
|
|
2027
|
+
if (similarApplications.length > 0 && isMonorepo) {
|
|
2028
|
+
// --application-ref does not apply to a monorepo (the CLI says so above),
|
|
2029
|
+
// 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.
|
|
2032
|
+
pendingSteps.push({
|
|
2033
|
+
step: stepNum++,
|
|
2034
|
+
action: "review_similar_applications",
|
|
2035
|
+
instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
|
|
2036
|
+
+ ` workspace resemble${similarApplications.length === 1 ? "s" : ""} this repository: ${matchList}.`
|
|
2037
|
+
+ " One of the deployables just registered may be the same system — for example a web app registered"
|
|
2038
|
+
+ " 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.",
|
|
2041
|
+
notes: [
|
|
2042
|
+
"Do not report the registration as complete without mentioning these.",
|
|
2043
|
+
"The match is on the repository, not a specific deployable: say which one you believe it is, and why.",
|
|
2044
|
+
"A human decides which is the same application — do not merge or decline on your own judgement.",
|
|
2045
|
+
],
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
else if (similarApplications.length > 0) {
|
|
2049
|
+
pendingSteps.push({
|
|
2050
|
+
step: stepNum++,
|
|
2051
|
+
action: "review_similar_applications",
|
|
2052
|
+
instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
|
|
2053
|
+
+ ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered: ${matchList}.`
|
|
2054
|
+
+ " Tell the human about these before enriching anything. This registration was created as a"
|
|
2055
|
+
+ " PROPOSED application and is not in the canonical graph yet, so nothing is broken — but if one of"
|
|
2056
|
+
+ " these is the same application, it should be mapped rather than duplicated.",
|
|
2057
|
+
commandTemplates: {
|
|
2058
|
+
mapInstead: `nexarch init-project --dir . --application-ref "<entityRef from the list above>"`,
|
|
2059
|
+
},
|
|
2060
|
+
notes: [
|
|
2061
|
+
"Do not report the registration as complete without mentioning these.",
|
|
2062
|
+
"A human decides which is the same application — do not merge or decline on your own judgement.",
|
|
2063
|
+
],
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
1876
2066
|
if (projectConstruct) {
|
|
1877
2067
|
pendingSteps.push({
|
|
1878
2068
|
step: stepNum++,
|
|
1879
2069
|
action: "describe_project",
|
|
1880
|
-
instruction: `Give the project (repository) entity a meaningful display name (not the raw directory name "${projectDirName}") and a short description of what lives in this repo
|
|
2070
|
+
instruction: `Give the project (repository) entity a meaningful display name (not the raw directory name "${projectDirName}") and a short description of what lives in this repo. ${DESCRIPTION_GUIDANCE}`,
|
|
1881
2071
|
command: `nexarch update-entity --key "${projectEntityKey}" --entity-type "project" --name "..." --description "..."`,
|
|
1882
2072
|
});
|
|
1883
2073
|
}
|
|
@@ -1897,8 +2087,8 @@ export async function initProject(args) {
|
|
|
1897
2087
|
step: stepNum++,
|
|
1898
2088
|
action: "classify_sub_packages",
|
|
1899
2089
|
instruction: projectConstruct
|
|
1900
|
-
? `For each sub-package in classifyPackages, run update-entity to confirm type/subtype/name/description. The sourced_from relationship to the project is wired automatically — no structural add-relationship is needed. The external key includes the entity type as a prefix — if you change the entity type, the key changes (e.g. application_component:foo → application:foo) and the old key's sourced_from should be retired.`
|
|
1901
|
-
:
|
|
2090
|
+
? `For each sub-package in classifyPackages, run update-entity to confirm type/subtype/name/description. ${DESCRIPTION_GUIDANCE} The sourced_from relationship to the project is wired automatically — no structural add-relationship is needed. The external key includes the entity type as a prefix — if you change the entity type, the key changes (e.g. application_component:foo → application:foo) and the old key's sourced_from should be retired.`
|
|
2091
|
+
: `${DESCRIPTION_GUIDANCE} For each sub-package in classifyPackages: (1) run update-entity to confirm type/subtype/name/description, then (2) immediately run add-relationship to wire the structural relationship. The external key includes the entity type as a prefix — if you change the entity type, the key changes (e.g. application_component:foo → application:foo). Always run update-entity before add-relationship for each package.`,
|
|
1902
2092
|
commandTemplates: projectConstruct
|
|
1903
2093
|
? {
|
|
1904
2094
|
updateEntity: `nexarch update-entity --key "<subPackageExternalKey>" --entity-type "<entityType>" --subtype "<subtype>" --name "..." --description "..."`,
|
|
@@ -1923,7 +2113,10 @@ export async function initProject(args) {
|
|
|
1923
2113
|
pendingSteps.push({
|
|
1924
2114
|
step: stepNum++,
|
|
1925
2115
|
action: "discover_functions",
|
|
1926
|
-
instruction: `Review the codebase to identify discrete application functions (what the application does).
|
|
2116
|
+
instruction: `Review the codebase to identify discrete application functions (what the application does). ` +
|
|
2117
|
+
`Examine named modules, route layout, service boundaries, and any architecture documentation. ` +
|
|
2118
|
+
`Register functions as application_function entities with subtype core_function (primary business function), supporting_function (auxiliary/enablement), integration_function (external connectivity), or data_function (data processing). ` +
|
|
2119
|
+
`Only register functions clearly evidenced by the codebase — do not invent them. ${DESCRIPTION_GUIDANCE}`,
|
|
1927
2120
|
commandTemplates: {
|
|
1928
2121
|
updateEntity: `nexarch update-entity --key "application_function:${projectSlug}_<function_slug>" --entity-type application_function --subtype core_function --name "..." --description "..."`,
|
|
1929
2122
|
addRelationship: `nexarch add-relationship --from "application_function:${projectSlug}_<function_slug>" --to ${projectConstruct && isMonorepo ? '"<owning application key from classifyPackages>"' : `"${projectExternalKey}"`} --type part_of`,
|
|
@@ -2127,7 +2320,7 @@ export async function initProject(args) {
|
|
|
2127
2320
|
}
|
|
2128
2321
|
if (unresolvedItems.length > 0) {
|
|
2129
2322
|
lines.push("");
|
|
2130
|
-
lines.push(
|
|
2323
|
+
lines.push(`${unresolvedItems.length} detected dependencies are not yet in the Nexarch catalogue. They will be added to your graph automatically once they are:`);
|
|
2131
2324
|
const sample = unresolvedItems.slice(0, 20).map((r) => r.input);
|
|
2132
2325
|
lines.push(` ${JSON.stringify(sample)}`);
|
|
2133
2326
|
if (unresolvedItems.length > 20)
|
|
@@ -2168,7 +2361,7 @@ export async function initProject(args) {
|
|
|
2168
2361
|
lines.push(` Examine named modules, route layout, service boundaries, and any architecture documentation.`);
|
|
2169
2362
|
lines.push(` Use subtype core_function (primary business function), supporting_function (auxiliary/enablement),`);
|
|
2170
2363
|
lines.push(` integration_function (external connectivity), or data_function (data processing).`);
|
|
2171
|
-
lines.push(` Only register functions clearly evidenced by the codebase — do not invent them
|
|
2364
|
+
lines.push(` Only register functions clearly evidenced by the codebase — do not invent them. ${DESCRIPTION_GUIDANCE}`);
|
|
2172
2365
|
lines.push(` For each one found:`);
|
|
2173
2366
|
const fnOwnerTarget = projectConstruct && isMonorepo ? '"<owning application key from CLASSIFY_THESE>"' : `"${projectExternalKey}"`;
|
|
2174
2367
|
lines.push(` nexarch update-entity --key "application_function:${projectExternalKey.split(":")[1] ?? "project"}_<function_slug>" --entity-type application_function --subtype core_function --name "..." --description "..."`);
|
|
@@ -2238,6 +2431,7 @@ export async function initProject(args) {
|
|
|
2238
2431
|
},
|
|
2239
2432
|
resolved: resolvedItems.length,
|
|
2240
2433
|
unresolved: unresolvedItems.length,
|
|
2434
|
+
similarApplications,
|
|
2241
2435
|
entityErrors: entitiesResult.errors ?? [],
|
|
2242
2436
|
relationshipErrors: relsResult?.errors ?? [],
|
|
2243
2437
|
enrichmentRequired,
|
|
@@ -20,15 +20,15 @@ function parseToolText(result) {
|
|
|
20
20
|
export async function policyAuditResults(args) {
|
|
21
21
|
const asJson = parseFlag(args, "--json");
|
|
22
22
|
if (parseFlag(args, "--help") || parseFlag(args, "-h")) {
|
|
23
|
-
console.log(`
|
|
24
|
-
Usage:
|
|
25
|
-
nexarch policy-audit-results --entity <applicationEntityRef> [--limit <1-10>] [--json]
|
|
26
|
-
|
|
27
|
-
Options:
|
|
28
|
-
--entity <key> Required application reference (e.g. application:my-service).
|
|
29
|
-
Aliases: --entity-ref, --application-ref, --application-key
|
|
30
|
-
--limit <n> Number of most recent runs to return (default 1, max 10)
|
|
31
|
-
--json Print JSON response
|
|
23
|
+
console.log(`
|
|
24
|
+
Usage:
|
|
25
|
+
nexarch policy-audit-results --entity <applicationEntityRef> [--limit <1-10>] [--json]
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--entity <key> Required application reference (e.g. application:my-service).
|
|
29
|
+
Aliases: --entity-ref, --application-ref, --application-key
|
|
30
|
+
--limit <n> Number of most recent runs to return (default 1, max 10)
|
|
31
|
+
--json Print JSON response
|
|
32
32
|
`);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
@@ -85,10 +85,18 @@ Options:
|
|
|
85
85
|
console.log(` Summary: ${summary.passCount ?? 0} pass, ${summary.partialCount ?? 0} partial, ${summary.failCount ?? 0} fail` +
|
|
86
86
|
(summary.totalRules !== undefined ? ` (of ${summary.totalRules})` : ""));
|
|
87
87
|
}
|
|
88
|
+
if (run.auditState) {
|
|
89
|
+
const required = summary?.byLevel?.required;
|
|
90
|
+
const requiredPct = run.requiredPassRate === null || run.requiredPassRate === undefined ? "n/a" : `${Math.round(run.requiredPassRate * 100)}%`;
|
|
91
|
+
console.log(` State: ${run.auditState} — required rules ${requiredPct} pass` +
|
|
92
|
+
(required ? ` (${required.pass ?? 0} of ${required.total ?? 0}, ${required.partial ?? 0} partial, ${required.fail ?? 0} fail)` : "") +
|
|
93
|
+
`; advisory findings: ${run.advisoryIssueCount ?? 0} (do not affect state)`);
|
|
94
|
+
}
|
|
88
95
|
for (const control of run.controls ?? []) {
|
|
89
96
|
console.log(`\n - ${control.controlName} (${control.controlId})`);
|
|
90
97
|
for (const rule of control.rules ?? []) {
|
|
91
|
-
const
|
|
98
|
+
const qualifier = rule.requirementQualifier ? ` ${rule.requirementQualifier}` : "";
|
|
99
|
+
const level = rule.requirementLevel ? ` [${rule.requirementLevel}${qualifier}]` : "";
|
|
92
100
|
console.log(` • [${rule.result}] ${rule.ruleName}${level} (${rule.ruleId})`);
|
|
93
101
|
if (rule.rationale)
|
|
94
102
|
console.log(` ${rule.rationale}`);
|
|
@@ -118,23 +118,23 @@ function parseFindings(args) {
|
|
|
118
118
|
export async function policyAuditSubmit(args) {
|
|
119
119
|
const asJson = parseFlag(args, "--json");
|
|
120
120
|
if (parseFlag(args, "--help") || parseFlag(args, "-h")) {
|
|
121
|
-
console.log(`
|
|
122
|
-
Usage:
|
|
123
|
-
nexarch policy-audit-submit --command-id <id> --application-ref <key> [options]
|
|
124
|
-
|
|
125
|
-
Options:
|
|
126
|
-
--command-id <id> Required command id
|
|
127
|
-
--application-ref <key> Required application reference key (e.g. application:bad-driving)
|
|
128
|
-
--agent-key <key> Optional agent key (defaults from identity)
|
|
129
|
-
--finding <controlId|ruleId|result|rationale|missing1;missing2> Repeatable
|
|
130
|
-
--findings-json <json> JSON array of findings
|
|
131
|
-
--findings-file <path> Path to JSON array of findings
|
|
132
|
-
--json Print JSON response
|
|
133
|
-
|
|
134
|
-
Notes:
|
|
135
|
-
- Findings are rule-level (policyRuleId is required).
|
|
136
|
-
- You can submit partial findings multiple times for the same command.
|
|
137
|
-
- Get valid rule ids with: nexarch policy-controls --entity <application:key> --json
|
|
121
|
+
console.log(`
|
|
122
|
+
Usage:
|
|
123
|
+
nexarch policy-audit-submit --command-id <id> --application-ref <key> [options]
|
|
124
|
+
|
|
125
|
+
Options:
|
|
126
|
+
--command-id <id> Required command id
|
|
127
|
+
--application-ref <key> Required application reference key (e.g. application:bad-driving)
|
|
128
|
+
--agent-key <key> Optional agent key (defaults from identity)
|
|
129
|
+
--finding <controlId|ruleId|result|rationale|missing1;missing2> Repeatable
|
|
130
|
+
--findings-json <json> JSON array of findings
|
|
131
|
+
--findings-file <path> Path to JSON array of findings
|
|
132
|
+
--json Print JSON response
|
|
133
|
+
|
|
134
|
+
Notes:
|
|
135
|
+
- Findings are rule-level (policyRuleId is required).
|
|
136
|
+
- You can submit partial findings multiple times for the same command.
|
|
137
|
+
- Get valid rule ids with: nexarch policy-controls --entity <application:key> --json
|
|
138
138
|
`);
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
@@ -173,5 +173,15 @@ Notes:
|
|
|
173
173
|
console.log(`Run ID: ${result.runId}`);
|
|
174
174
|
if (result.summary) {
|
|
175
175
|
console.log(`Summary: ${result.summary.passCount ?? 0} pass, ${result.summary.partialCount ?? 0} partial, ${result.summary.failCount ?? 0} fail`);
|
|
176
|
+
if (result.summary.requiredIssueCount !== undefined) {
|
|
177
|
+
const pct = result.summary.requiredPassRate === null || result.summary.requiredPassRate === undefined
|
|
178
|
+
? "n/a"
|
|
179
|
+
: `${Math.round(result.summary.requiredPassRate * 100)}%`;
|
|
180
|
+
console.log(`Required rules: ${pct} pass, ${result.summary.requiredIssueCount} issue(s) — these decide the audit state`);
|
|
181
|
+
console.log(`Advisory findings: ${result.summary.advisoryIssueCount ?? 0} (recommended/informational; recorded, not gating)`);
|
|
182
|
+
}
|
|
183
|
+
if ((result.summary.remainingRules ?? 0) > 0) {
|
|
184
|
+
console.log(`Remaining rules to submit: ${result.summary.remainingRules}`);
|
|
185
|
+
}
|
|
176
186
|
}
|
|
177
187
|
}
|
|
@@ -45,7 +45,8 @@ export async function policyControls(args) {
|
|
|
45
45
|
continue;
|
|
46
46
|
}
|
|
47
47
|
for (const rule of rules) {
|
|
48
|
-
const
|
|
48
|
+
const qualifier = rule.requirementQualifier ? ` ${rule.requirementQualifier}` : "";
|
|
49
|
+
const level = rule.requirementLevel ? ` [${rule.requirementLevel}${qualifier}]` : "";
|
|
49
50
|
console.log(` • ${rule.name} (${rule.id})${level}`);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { join, resolve } from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { hashRegisteredSection } from "../lib/trust.js";
|
|
4
4
|
/**
|
|
5
5
|
* Verifies the trust attestation without anyone retyping it.
|
|
6
6
|
*
|
|
@@ -15,17 +15,6 @@ import { createHash } from "node:crypto";
|
|
|
15
15
|
*/
|
|
16
16
|
const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", ".github/copilot-instructions.md"];
|
|
17
17
|
const DEFAULT_VERIFY_BASE = "https://mcp.nexarch.ai/trust/verify";
|
|
18
|
-
/**
|
|
19
|
-
* Recomputes the hash of the "agent-registration" managed section exactly as
|
|
20
|
-
* `nexarch init-agent` hashed it before minting — see ADR-0112. Returns null
|
|
21
|
-
* when the file has no such section (nothing for content_hash to cover).
|
|
22
|
-
*/
|
|
23
|
-
function hashRegisteredSection(content) {
|
|
24
|
-
const match = content.match(/<!-- nexarch:agent-registration:start -->\n([\s\S]*?)\n<!-- nexarch:agent-registration:end -->/);
|
|
25
|
-
if (!match)
|
|
26
|
-
return null;
|
|
27
|
-
return createHash("sha256").update(match[1].trim(), "utf8").digest("hex");
|
|
28
|
-
}
|
|
29
18
|
function findAttestation(dir) {
|
|
30
19
|
for (const name of INSTRUCTION_FILES) {
|
|
31
20
|
const path = join(dir, name);
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { updateEntity } from "./commands/update-entity.js";
|
|
|
13
13
|
import { addRelationship } from "./commands/add-relationship.js";
|
|
14
14
|
import { registerAlias } from "./commands/register-alias.js";
|
|
15
15
|
import { resolveNames } from "./commands/resolve-names.js";
|
|
16
|
+
import { feedback } from "./commands/feedback.js";
|
|
16
17
|
import { listEntities } from "./commands/list-entities.js";
|
|
17
18
|
import { listRelationships } from "./commands/list-relationships.js";
|
|
18
19
|
import { checkIn } from "./commands/check-in.js";
|
|
@@ -56,6 +57,7 @@ const commands = {
|
|
|
56
57
|
"add-relationship": addRelationship,
|
|
57
58
|
"register-alias": registerAlias,
|
|
58
59
|
"resolve-names": resolveNames,
|
|
60
|
+
feedback,
|
|
59
61
|
"list-entities": listEntities,
|
|
60
62
|
"list-relationships": listRelationships,
|
|
61
63
|
"register-runtime": registerRuntime,
|
|
@@ -137,7 +139,7 @@ Usage:
|
|
|
137
139
|
nexarch init-agent Run handshake + mandatory agent registration in graph (advanced/manual)
|
|
138
140
|
Options: --agent-id <id> --bind-to-external-key <key>
|
|
139
141
|
--bind-relationship-type <code> --redact-hostname
|
|
140
|
-
--json --strict
|
|
142
|
+
--reattest --json --strict
|
|
141
143
|
nexarch agent identify
|
|
142
144
|
Capture richer coding-agent identity metadata
|
|
143
145
|
Options: --agent-id <id> --provider <provider> --model <model>
|
|
@@ -170,6 +172,7 @@ Usage:
|
|
|
170
172
|
--batch-size <n> upsert batch size (default: 10)
|
|
171
173
|
--profile include timing/profile data in JSON output
|
|
172
174
|
--dry-run preview without writing
|
|
175
|
+
--tool <terraform|opentofu> select the IaC state CLI
|
|
173
176
|
--json
|
|
174
177
|
nexarch update-project
|
|
175
178
|
Re-scan a previously registered project directory, refresh
|
|
@@ -224,6 +227,12 @@ Usage:
|
|
|
224
227
|
results before calling add-relationship.
|
|
225
228
|
Options: --names <csv> (required, e.g. "vercel,neon")
|
|
226
229
|
--json
|
|
230
|
+
nexarch feedback
|
|
231
|
+
Report a Nexarch usability problem or tool error.
|
|
232
|
+
Options: --kind <usability|error> (required)
|
|
233
|
+
--summary <text> (required, one line)
|
|
234
|
+
--detail <text> --severity <low|medium|high>
|
|
235
|
+
--surface <page|tool|command> --json
|
|
227
236
|
nexarch list-entities
|
|
228
237
|
List entities from the workspace graph.
|
|
229
238
|
Options: --type <entityTypeCode>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { join, relative, sep } from "path";
|
|
3
|
+
import { environmentFromPath } from "./iac-detect.js";
|
|
4
|
+
const SCAN_DEPTH = 8;
|
|
5
|
+
const IGNORED = new Set([".git", "node_modules", ".venv", "venv", "dist", "build", ".next"]);
|
|
6
|
+
const YAML = /\.ya?ml$/i;
|
|
7
|
+
function walk(dir, depth = 0, files = []) {
|
|
8
|
+
if (depth > SCAN_DEPTH)
|
|
9
|
+
return files;
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = readdirSync(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return files;
|
|
16
|
+
}
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (IGNORED.has(entry))
|
|
19
|
+
continue;
|
|
20
|
+
const full = join(dir, entry);
|
|
21
|
+
try {
|
|
22
|
+
if (statSync(full).isDirectory())
|
|
23
|
+
walk(full, depth + 1, files);
|
|
24
|
+
else
|
|
25
|
+
files.push(full);
|
|
26
|
+
}
|
|
27
|
+
catch { /* unreadable paths are not evidence */ }
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
function hasPlaybookShape(file) {
|
|
32
|
+
if (!YAML.test(file))
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
const text = readFileSync(file, "utf8");
|
|
36
|
+
return /^\s*-?\s*hosts\s*:/m.test(text) && /^\s*(tasks|roles|pre_tasks|post_tasks|handlers)\s*:/m.test(text);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function normalise(repoDir, file) {
|
|
43
|
+
return relative(repoDir, file).split(sep).join("/");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The environment an inventory is for, from its path.
|
|
47
|
+
*
|
|
48
|
+
* Terraform gets this from a root module directory or a workspace. Ansible has
|
|
49
|
+
* neither, and this is the gap the plan called out as the hard part: the
|
|
50
|
+
* environment lives in how the inventory is filed. `inventories/production/hosts`
|
|
51
|
+
* and `inventory/prod.ini` both say it plainly, so the filename is consulted as
|
|
52
|
+
* well as the directories -- which is where `environmentFromPath` alone stops,
|
|
53
|
+
* since a Terraform root module is always a directory.
|
|
54
|
+
*
|
|
55
|
+
* The vocabulary is deliberately shared with `environmentFromPath` rather than
|
|
56
|
+
* copied: two lists of environment names drift, and then the same repository
|
|
57
|
+
* reports different environments depending on which tool read it.
|
|
58
|
+
*/
|
|
59
|
+
export function environmentFromInventoryPath(relativePath) {
|
|
60
|
+
const fromDirectories = environmentFromPath(relativePath);
|
|
61
|
+
if (fromDirectories)
|
|
62
|
+
return fromDirectories;
|
|
63
|
+
const base = (relativePath.split("/").at(-1) ?? "").replace(/\.(ini|ya?ml|json)$/i, "");
|
|
64
|
+
return base ? environmentFromPath(base) : null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Detects Ansible from corroborating repository evidence.
|
|
68
|
+
*
|
|
69
|
+
* A YAML file containing `hosts:` is not enough: application configuration
|
|
70
|
+
* regularly has the same vocabulary. Like IaC detection, a playbook needs a
|
|
71
|
+
* second operational signal (inventory, roles, configuration, or collection)
|
|
72
|
+
* before it can change onboarding behaviour.
|
|
73
|
+
*/
|
|
74
|
+
export function detectAnsibleProject(repoDir) {
|
|
75
|
+
const files = walk(repoDir);
|
|
76
|
+
const relativeFiles = files.map((file) => normalise(repoDir, file));
|
|
77
|
+
const playbookFiles = files.filter(hasPlaybookShape).map((file) => normalise(repoDir, file)).sort();
|
|
78
|
+
const inventoryFiles = relativeFiles.filter((file) => {
|
|
79
|
+
const base = file.split("/").at(-1) ?? "";
|
|
80
|
+
return /(^|\/)(inventory|inventories)(\/|$)/i.test(file) || /^(hosts|inventory)(\.ini|\.ya?ml)?$/i.test(base);
|
|
81
|
+
}).sort();
|
|
82
|
+
const roleDirectories = relativeFiles
|
|
83
|
+
.filter((file) => /(^|\/)roles\/[^/]+\/(tasks|handlers|defaults|vars)\/main\.ya?ml$/i.test(file))
|
|
84
|
+
.map((file) => file.replace(/\/(tasks|handlers|defaults|vars)\/main\.ya?ml$/i, ""))
|
|
85
|
+
.filter((value, index, values) => values.indexOf(value) === index)
|
|
86
|
+
.sort();
|
|
87
|
+
const collectionDirectories = relativeFiles
|
|
88
|
+
.filter((file) => /(^|\/)collections\/requirements\.ya?ml$/i.test(file) || /(^|\/)galaxy\.ya?ml$/i.test(file))
|
|
89
|
+
.map((file) => file.replace(/\/(requirements|galaxy)\.ya?ml$/i, ""))
|
|
90
|
+
.filter((value, index, values) => values.indexOf(value) === index)
|
|
91
|
+
.sort();
|
|
92
|
+
const hasConfig = existsSync(join(repoDir, "ansible.cfg"));
|
|
93
|
+
const signals = [];
|
|
94
|
+
if (playbookFiles.length)
|
|
95
|
+
signals.push(`${playbookFiles.length} playbook${playbookFiles.length === 1 ? "" : "s"}`);
|
|
96
|
+
if (inventoryFiles.length)
|
|
97
|
+
signals.push(`${inventoryFiles.length} inventory file${inventoryFiles.length === 1 ? "" : "s"}`);
|
|
98
|
+
if (roleDirectories.length)
|
|
99
|
+
signals.push(`${roleDirectories.length} role${roleDirectories.length === 1 ? "" : "s"}`);
|
|
100
|
+
if (collectionDirectories.length)
|
|
101
|
+
signals.push(`${collectionDirectories.length} collection declaration${collectionDirectories.length === 1 ? "" : "s"}`);
|
|
102
|
+
if (hasConfig)
|
|
103
|
+
signals.push("ansible.cfg");
|
|
104
|
+
const environments = [...new Set(inventoryFiles.map(environmentFromInventoryPath).filter((e) => Boolean(e)))].sort();
|
|
105
|
+
if (environments.length)
|
|
106
|
+
signals.push(`environments: ${environments.join(", ")}`);
|
|
107
|
+
const corroborated = inventoryFiles.length > 0 || roleDirectories.length > 0 || collectionDirectories.length > 0 || hasConfig;
|
|
108
|
+
return { isAnsible: playbookFiles.length > 0 && corroborated, signals, playbookFiles, inventoryFiles, environments, roleDirectories, collectionDirectories };
|
|
109
|
+
}
|