nexarch 0.13.0 → 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";
@@ -1238,6 +1238,63 @@ 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
+ /**
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
+ }
1241
1298
  // ─── Main command ─────────────────────────────────────────────────────────────
1242
1299
  export async function initProject(args) {
1243
1300
  const asJson = parseFlag(args, "--json");
@@ -1307,6 +1364,7 @@ export async function initProject(args) {
1307
1364
  nonInteractive: parseFlag(args, "--non-interactive"),
1308
1365
  skipIngest: parseFlag(args, "--no-ingest"),
1309
1366
  dryRun,
1367
+ tool: parseOptionValue(args, "--tool"),
1310
1368
  }, infrastructure);
1311
1369
  }
1312
1370
  return;
@@ -1325,6 +1383,7 @@ export async function initProject(args) {
1325
1383
  nonInteractive: parseFlag(args, "--non-interactive"),
1326
1384
  skipIngest: parseFlag(args, "--no-ingest"),
1327
1385
  dryRun,
1386
+ tool: parseOptionValue(args, "--tool"),
1328
1387
  }, infrastructure);
1329
1388
  return;
1330
1389
  }
@@ -1399,11 +1458,17 @@ export async function initProject(args) {
1399
1458
  console.log("\nResolving against reference library…");
1400
1459
  const allResolveResults = [];
1401
1460
  const BATCH_SIZE = 200;
1461
+ const dependentRefsByName = dependentRefsForNames({
1462
+ subPackages,
1463
+ rootDepNames,
1464
+ rootDependentRef: isMonorepo ? null : projectExternalKey,
1465
+ });
1402
1466
  logProgress("resolve.start", `count=${detectedNames.length}, batchSize=${BATCH_SIZE}`);
1403
1467
  for (let i = 0; i < detectedNames.length; i += BATCH_SIZE) {
1404
1468
  const batch = detectedNames.slice(i, i + BATCH_SIZE);
1405
1469
  logProgress("resolve.batch", `${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(detectedNames.length / BATCH_SIZE)} size=${batch.length}`);
1406
- const raw = await callMcpProfiled("nexarch_resolve_reference", { names: batch, companyId: creds.companyId, projectRef: projectEntityKey }, { batchSize: batch.length });
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 });
1407
1472
  const data = parseToolText(raw);
1408
1473
  allResolveResults.push(...data.results);
1409
1474
  }
@@ -1546,6 +1611,37 @@ export async function initProject(args) {
1546
1611
  }
1547
1612
  }
1548
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
+ }
1549
1645
  logProgress(projectConstruct ? "project.target" : "application.target", projectConstruct ? (isMonorepo ? projectEntityKey : `${projectEntityKey} + ${projectExternalKey}`) : projectExternalKey);
1550
1646
  // In refresh mode, snapshot the current graph state for this project before writing,
1551
1647
  // so we can diff what changed and surface stale relationships to the agent.
@@ -1925,13 +2021,36 @@ export async function initProject(args) {
1925
2021
  // this application already exists under another name, everything after this
1926
2022
  // enriches a duplicate. The scan has always computed these matches; until
1927
2023
  // now it printed them only in human mode, so the agent never saw them.
1928
- if (similarApplications.length > 0) {
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) {
1929
2049
  pendingSteps.push({
1930
2050
  step: stepNum++,
1931
2051
  action: "review_similar_applications",
1932
2052
  instruction: `${similarApplications.length} existing application${similarApplications.length === 1 ? "" : "s"} in this`
1933
- + ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered:`
1934
- + ` ${similarApplications.map((m) => `${m.name} (${m.entityRef}, score ${m.score.toFixed(2)}, ${m.reasons.join(", ")})`).join("; ")}.`
2053
+ + ` workspace resemble${similarApplications.length === 1 ? "s" : ""} the one just registered: ${matchList}.`
1935
2054
  + " Tell the human about these before enriching anything. This registration was created as a"
1936
2055
  + " PROPOSED application and is not in the canonical graph yet, so nothing is broken — but if one of"
1937
2056
  + " these is the same application, it should be mapped rather than duplicated.",
package/dist/index.js CHANGED
@@ -172,6 +172,7 @@ Usage:
172
172
  --batch-size <n> upsert batch size (default: 10)
173
173
  --profile include timing/profile data in JSON output
174
174
  --dry-run preview without writing
175
+ --tool <terraform|opentofu> select the IaC state CLI
175
176
  --json
176
177
  nexarch update-project
177
178
  Re-scan a previously registered project directory, refresh
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ import { parse } from "yaml";
2
+ export const DEFAULT_ANSIBLE_HOST_MAPPING = {
3
+ entityTypeCode: "platform_component",
4
+ entitySubtypeCode: null,
5
+ role: "compute_host",
6
+ resourceType: "ansible_host",
7
+ };
8
+ /** Ansible's own bookkeeping groups: every host is in them, so they classify nothing. */
9
+ const BOOKKEEPING_GROUPS = new Set(["all", "ungrouped"]);
10
+ function hostNamesOf(raw) {
11
+ const hosts = raw.hosts;
12
+ const names = Array.isArray(hosts) ? hosts : hosts && typeof hosts === "object" ? Object.keys(hosts) : [];
13
+ return names.filter((name) => typeof name === "string" && Boolean(name.trim()));
14
+ }
15
+ function childNamesOf(raw) {
16
+ const children = raw.children;
17
+ const names = Array.isArray(children) ? children : children && typeof children === "object" ? Object.keys(children) : [];
18
+ return names.filter((name) => typeof name === "string" && Boolean(name.trim()));
19
+ }
20
+ /**
21
+ * Resolves group membership, following `children`.
22
+ *
23
+ * Nested groups are how real inventories express environment: `prod` holds no
24
+ * hosts of its own, only `children: [web, db]`. Reading `hosts` alone gives a
25
+ * host the group it was listed under and loses every ancestor -- which is to
26
+ * say, it loses the environment, the one thing the group tree is usually for.
27
+ *
28
+ * Shared by both parsers on purpose. JSON and INI express the same tree in
29
+ * different syntax, and resolving it twice is how the two formats come to
30
+ * disagree about the same estate.
31
+ *
32
+ * The seen set is against cycles, which Ansible rejects at runtime but which
33
+ * can sit in a file and must not hang the scan.
34
+ */
35
+ function resolveMemberships(tree) {
36
+ const hostsUnder = (group, seen = new Set()) => {
37
+ if (seen.has(group))
38
+ return [];
39
+ seen.add(group);
40
+ return [
41
+ ...(tree.hosts.get(group) ?? []),
42
+ ...(tree.children.get(group) ?? []).flatMap((child) => hostsUnder(child, seen)),
43
+ ];
44
+ };
45
+ const memberships = new Map();
46
+ for (const group of new Set([...tree.hosts.keys(), ...tree.children.keys()])) {
47
+ for (const name of hostsUnder(group)) {
48
+ const memberOf = memberships.get(name) ?? new Set();
49
+ if (!BOOKKEEPING_GROUPS.has(group))
50
+ memberOf.add(group);
51
+ memberships.set(name, memberOf);
52
+ }
53
+ }
54
+ return [...memberships.entries()]
55
+ .map(([name, groups]) => ({ name, groups: [...groups].sort() }))
56
+ .sort((a, b) => a.name.localeCompare(b.name));
57
+ }
58
+ function groupsFromObject(value) {
59
+ if (!value || typeof value !== "object" || Array.isArray(value))
60
+ return [];
61
+ const tree = { hosts: new Map(), children: new Map() };
62
+ for (const [group, raw] of Object.entries(value)) {
63
+ if (group === "_meta" || !raw || typeof raw !== "object" || Array.isArray(raw))
64
+ continue;
65
+ tree.hosts.set(group, hostNamesOf(raw));
66
+ tree.children.set(group, childNamesOf(raw));
67
+ }
68
+ return resolveMemberships(tree);
69
+ }
70
+ function parseIni(text) {
71
+ const tree = { hosts: new Map(), children: new Map() };
72
+ let group = "ungrouped";
73
+ // "hosts", "children" or "vars" — an INI section header says which, and a
74
+ // `:children` section is the nesting the JSON form spells as `children`.
75
+ let section = "hosts";
76
+ const push = (map, key, value) => {
77
+ map.set(key, [...(map.get(key) ?? []), value]);
78
+ };
79
+ for (const line of text.split(/\r?\n/)) {
80
+ const clean = line.replace(/\s[;#].*$/, "").trim();
81
+ const header = clean.match(/^\[([^\]]+)\]$/);
82
+ if (header) {
83
+ const suffix = /:(children|vars)$/.exec(header[1])?.[1];
84
+ section = suffix === "children" ? "children" : suffix === "vars" ? "vars" : "hosts";
85
+ group = header[1].replace(/:(children|vars)$/, "");
86
+ continue;
87
+ }
88
+ if (!clean || section === "vars" || clean.startsWith("#") || clean.startsWith(";"))
89
+ continue;
90
+ // `web-01 ansible_host=10.0.0.5` is an ordinary host line: inline variables
91
+ // are how INI inventories are usually written. Skipping every line
92
+ // containing `=` dropped exactly those hosts, which is most of them. The
93
+ // `:vars` section is already excluded above, so the only thing that rule
94
+ // caught was the common case.
95
+ const name = clean.split(/\s+/)[0];
96
+ if (!name || name.includes("=") || name.includes(":"))
97
+ continue;
98
+ push(section === "children" ? tree.children : tree.hosts, group, name);
99
+ }
100
+ return resolveMemberships(tree);
101
+ }
102
+ /** Parses `ansible-inventory --list --export` JSON/YAML, or a conventional INI inventory. */
103
+ export function parseAnsibleInventory(input) {
104
+ if (input && typeof input === "object")
105
+ return { hosts: groupsFromObject(input) };
106
+ const text = input.trim();
107
+ if (!text)
108
+ return { hosts: [] };
109
+ // `{` only. An INI inventory conventionally opens with a group header, so
110
+ // treating a leading `[` as JSON sent every one of them to JSON.parse and
111
+ // threw on the most common inventory format there is. A JSON array is not
112
+ // valid `ansible-inventory --list` output anyway.
113
+ if (text.startsWith("{"))
114
+ return { hosts: groupsFromObject(JSON.parse(text)) };
115
+ if (/^\s*\w[\w-]*\s*:/m.test(text) && !/^\s*\[/m.test(text))
116
+ return { hosts: groupsFromObject(parse(text)) };
117
+ return { hosts: parseIni(text) };
118
+ }
119
+ /**
120
+ * Projects only inventory identity and group membership. In particular,
121
+ * `_meta.hostvars`, inline `ansible_*` variables, vault values, and connection
122
+ * settings are intentionally not present in either attributes or tags.
123
+ */
124
+ export function projectAnsibleInventory(params) {
125
+ const mapping = params.mapping ?? DEFAULT_ANSIBLE_HOST_MAPPING;
126
+ const resources = params.inventory.hosts.map((host) => ({
127
+ address: `ansible:${host.name}`,
128
+ resourceType: mapping.resourceType,
129
+ entityTypeCode: mapping.entityTypeCode,
130
+ entitySubtypeCode: mapping.entitySubtypeCode ?? null,
131
+ role: mapping.role,
132
+ // Not "managed". Ansible configures hosts that already exist -- the
133
+ // inventory is a list of machines someone else provisioned. Recording them
134
+ // as managed would have the graph claim we created a server we only
135
+ // installed packages on, and `IacTool.provisions` exists to carry exactly
136
+ // this distinction.
137
+ mode: "declared",
138
+ name: host.name,
139
+ attributes: { inventory_hostname: host.name, ansible_groups: [...host.groups].sort() },
140
+ tags: {},
141
+ identityIds: [],
142
+ }));
143
+ return {
144
+ environment: params.environment,
145
+ iacToolVersion: null,
146
+ resources,
147
+ unknownTypes: [],
148
+ stats: { resourcesSeen: resources.length, resourcesProjected: resources.length, attributesDroppedNotAllowlisted: 0, attributesDroppedSensitive: 0 },
149
+ };
150
+ }