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.
@@ -5,7 +5,7 @@ import { basename, join, relative, resolve as resolvePath } from "node:path";
5
5
  import { requireCredentials } from "../lib/credentials.js";
6
6
  import { callMcpTool } from "../lib/mcp.js";
7
7
  import { buildVersionAttributes } from "../lib/version-normalization.js";
8
- import { detectInfrastructureProject } from "../lib/terraform-detect.js";
8
+ import { detectInfrastructureProject } from "../lib/iac-detect.js";
9
9
  import { runInfrastructureOnboarding } from "./init-project-infra.js";
10
10
  import { detectDataProject } from "../lib/data-project-detect.js";
11
11
  import { runDataProjectOnboarding } from "./init-project-data.js";
@@ -1165,7 +1165,36 @@ function extractHost(input) {
1165
1165
  return null;
1166
1166
  }
1167
1167
  }
1168
- export function scoreApplicationCandidate(app, projectName, repoUrl) {
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
- if (repoHost && appWebsiteHost && repoHost === appWebsiteHost) {
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 raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId }, { batchSize: batch.length });
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} unresolved (logged to reference candidates)`);
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
- : `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.`,
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). Examine named modules, route layout, service boundaries, and any architecture documentation. 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). Only register functions clearly evidenced by the codebase — do not invent them.`,
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(`UNRESOLVED (${unresolvedItems.length} names not matched in reference library):`);
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 level = rule.requirementLevel ? ` [${rule.requirementLevel}]` : "";
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 level = rule.requirementLevel ? ` [${rule.requirementLevel}]` : "";
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 { createHash } from "node:crypto";
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
+ }