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.
@@ -0,0 +1,51 @@
1
+ import process from "process";
2
+ import { requireCredentials } from "../lib/credentials.js";
3
+ import { callMcpTool } from "../lib/mcp.js";
4
+ function option(args, name) {
5
+ const index = args.indexOf(name);
6
+ const value = index === -1 ? null : args[index + 1];
7
+ return value && !value.startsWith("--") ? value : null;
8
+ }
9
+ export async function feedback(args) {
10
+ const kind = option(args, "--kind");
11
+ const summary = option(args, "--summary");
12
+ const asJson = args.includes("--json");
13
+ if (kind !== "usability" && kind !== "error") {
14
+ console.error("error: --kind usability|error is required");
15
+ process.exit(1);
16
+ }
17
+ if (!summary || summary.length > 300 || /[\r\n]/.test(summary)) {
18
+ console.error("error: --summary is required, must be one line, and may not exceed 300 characters");
19
+ process.exit(1);
20
+ }
21
+ const severity = option(args, "--severity");
22
+ if (severity && !["low", "medium", "high"].includes(severity)) {
23
+ console.error("error: --severity must be low, medium, or high");
24
+ process.exit(1);
25
+ }
26
+ const creds = requireCredentials();
27
+ const result = await callMcpTool("nexarch_submit_feedback", {
28
+ kind,
29
+ summary,
30
+ detail: option(args, "--detail") ?? undefined,
31
+ severity: severity ?? undefined,
32
+ surface: option(args, "--surface") ?? undefined,
33
+ agentContext: {
34
+ agentId: "nexarch-cli",
35
+ agentRunId: `cli-feedback-${Date.now()}`,
36
+ repoRef: "project:terminal_feedback",
37
+ observedAt: new Date().toISOString(),
38
+ client: "nexarch-cli",
39
+ },
40
+ }, { companyId: creds.companyId });
41
+ const payload = JSON.parse(result.content?.[0]?.text ?? "{}");
42
+ if (asJson) {
43
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
44
+ return;
45
+ }
46
+ if (payload.error) {
47
+ console.error(`${payload.error}: ${payload.message ?? "Feedback was not recorded"}`);
48
+ process.exit(1);
49
+ }
50
+ console.log(`${payload.acknowledged ?? "Feedback recorded."}${payload.id ? ` (${payload.id})` : ""}`);
51
+ }
@@ -8,15 +8,22 @@ function parseToolText(result) {
8
8
  const text = result.content?.[0]?.text ?? "{}";
9
9
  return JSON.parse(text);
10
10
  }
11
+ function formatPct(rate) {
12
+ if (rate === null || rate === undefined || !Number.isFinite(rate))
13
+ return "n/a";
14
+ return `${Math.round(rate * 100)}%`;
15
+ }
11
16
  export async function governanceSummary(args) {
12
17
  const asJson = parseFlag(args, "--json");
13
18
  if (parseFlag(args, "--help") || parseFlag(args, "-h")) {
14
- console.log(`
15
- Usage:
16
- nexarch governance-summary [--json]
17
-
18
- Returns review queue counts, graph stats, and a per-application policy
19
- audit rollup (latest run status, pass/partial/fail counts).
19
+ console.log(`
20
+ Usage:
21
+ nexarch governance-summary [--json]
22
+
23
+ Returns review queue counts, graph stats, and a per-application policy
24
+ audit rollup (latest run status, pass/partial/fail counts). Audit state is
25
+ decided by required rules only; recommended and informational findings are
26
+ listed as advisory and never set attention_needed.
20
27
  `);
21
28
  return;
22
29
  }
@@ -34,11 +41,16 @@ audit rollup (latest run status, pass/partial/fail counts).
34
41
  console.log(` Canonical coverage: ${result.canonicalCoverageOverallPct ?? 0}% overall ` +
35
42
  `(entities ${result.canonicalCoverageEntityPct ?? 0}%, relationships ${result.canonicalCoverageRelationshipPct ?? 0}%)`);
36
43
  console.log(` Canonical registry size: ${result.canonicalRegistryCount ?? 0}`);
44
+ const ladder = result.governanceLadder;
45
+ if (ladder?.state) {
46
+ console.log(` Governance ladder: ${ladder.state} (packs available ${ladder.packsAvailable ?? 0}, installed ${ladder.packsInstalled ?? 0}, applied ${ladder.packsApplied ?? 0}, apps audited ${ladder.appsAudited ?? 0})`);
47
+ }
37
48
  const audit = result.policyAudit;
38
49
  if (audit) {
39
- console.log("\nPolicy audit rollup");
50
+ console.log("\nPolicy audit rollup (state decided by required rules only)");
40
51
  console.log(` Applications: ${audit.totalApplications} (audited ${audit.appsAudited}, missing audit ${audit.appsWithoutAudit})`);
41
- console.log(` With failures: ${audit.appsWithFailures}, with partials only: ${audit.appsWithPartials}, clean: ${audit.appsClean}`);
52
+ console.log(` Required failures: ${audit.appsWithFailures}, required partials only: ${audit.appsWithPartials}, passing: ${audit.appsClean}` +
53
+ (audit.appsWithAdvisories !== undefined ? `, with advisory findings: ${audit.appsWithAdvisories}` : ""));
42
54
  if (audit.applications.length > 0) {
43
55
  console.log("\n Per application:");
44
56
  for (const app of audit.applications) {
@@ -46,9 +58,15 @@ audit rollup (latest run status, pass/partial/fail counts).
46
58
  console.log(` - ${app.applicationName} (${app.applicationEntityRef}): no audit run`);
47
59
  continue;
48
60
  }
49
- console.log(` - ${app.applicationName} (${app.applicationEntityRef}): ${app.latestRunStatus} — ` +
61
+ const state = app.auditState ? ` [${app.auditState}]` : "";
62
+ const required = app.requiredTotal !== undefined && app.requiredTotal !== null
63
+ ? ` — required ${formatPct(app.requiredPassRate)} pass (${app.requiredFailCount ?? 0} fail, ${app.requiredPartialCount ?? 0} partial of ${app.requiredTotal})`
64
+ : "";
65
+ const advisory = (app.advisoryIssueCount ?? 0) > 0 ? `, ${app.advisoryIssueCount} advisory` : "";
66
+ console.log(` - ${app.applicationName} (${app.applicationEntityRef}): ${app.latestRunStatus}${state} — ` +
50
67
  `${app.passCount ?? 0} pass, ${app.partialCount ?? 0} partial, ${app.failCount ?? 0} fail` +
51
68
  (app.totalRules ? ` of ${app.totalRules}` : "") +
69
+ required + advisory +
52
70
  (app.completedAt ? ` (completed ${app.completedAt})` : ""));
53
71
  }
54
72
  }
@@ -1,10 +1,13 @@
1
- import { readFileSync, existsSync, readdirSync } from "fs";
1
+ import { readFileSync, existsSync, readdirSync, statSync } from "fs";
2
2
  import { execFileSync } from "child_process";
3
- import { resolve } from "path";
3
+ import { join, resolve } from "path";
4
4
  import { callMcpTool } from "../lib/mcp.js";
5
- import { parseTerraformStateBuffer, projectTerraformState, inferEnvironmentFromTags, environmentSubtypeFor, } from "../lib/terraform-projection.js";
5
+ import { parseIacStateBuffer, projectIacState, inferEnvironmentFromTags, environmentSubtypeFor, } from "../lib/iac-projection.js";
6
6
  import { loadIacCatalogue, describeCatalogue } from "../lib/iac-catalogue.js";
7
- import { discoverRootModules, environmentFromPath } from "../lib/terraform-detect.js";
7
+ import { discoverRootModules, environmentFromPath } from "../lib/iac-detect.js";
8
+ import { iacToolOrThrow } from "../lib/iac-tool.js";
9
+ import { detectAnsibleProject, environmentFromInventoryPath } from "../lib/ansible-detect.js";
10
+ import { parseAnsibleInventory, projectAnsibleInventory } from "../lib/ansible-projection.js";
8
11
  const ENTITY_BATCH = 50;
9
12
  /**
10
13
  * Terraform provider prefix -> the platform it provisions.
@@ -45,6 +48,16 @@ function detectPlatform(resourceTypes) {
45
48
  const [topPrefix] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
46
49
  return PROVIDER_PLATFORMS.find((p) => p.prefix === topPrefix) ?? null;
47
50
  }
51
+ /**
52
+ * "provisioned" or "configured", by tool.
53
+ *
54
+ * The same distinction `mode: "declared"` carries in the data, said in the
55
+ * prose a human reads on the entity. Ansible did not provision a machine it
56
+ * only installed packages on, and the description should not claim it did.
57
+ */
58
+ function pastVerbFor(tool) {
59
+ return tool.provisions ? "provisioned" : "configured";
60
+ }
48
61
  function readResult(raw) {
49
62
  return JSON.parse(raw.content?.[0]?.text ?? "{}");
50
63
  }
@@ -62,12 +75,16 @@ function parseArgs(args) {
62
75
  return index >= 0 && args[index + 1] ? args[index + 1] : null;
63
76
  };
64
77
  return {
65
- statePath: get("--state"),
78
+ // `--inventory` is the same thing said in Ansible's vocabulary. A reader
79
+ // holding an inventory file should not have to work out that we call it
80
+ // state.
81
+ statePath: get("--state") ?? get("--inventory"),
66
82
  environmentOverride: get("--environment"),
67
83
  dir: get("--dir") ?? process.cwd(),
68
84
  projectRef: get("--project-ref"),
69
85
  dryRun: args.includes("--dry-run"),
70
86
  asJson: args.includes("--json"),
87
+ tool: get("--tool"),
71
88
  };
72
89
  }
73
90
  /**
@@ -80,13 +97,14 @@ function parseArgs(args) {
80
97
  * an agent, and an agent can act on a list of commands but not on a request to
81
98
  * choose.
82
99
  */
83
- function resolveRootModule(dir) {
84
- const hasLocalTerraform = existsSync(dir) && readdirSync(dir).some((entry) => entry.endsWith(".tf"));
85
- if (hasLocalTerraform)
100
+ function resolveRootModule(dir, tool) {
101
+ // Both tools read .tf; OpenTofu also reads .tofu, which Terraform will not.
102
+ const hasLocalConfig = existsSync(dir) && readdirSync(dir).some((entry) => entry.endsWith(".tf") || entry.endsWith(".tofu"));
103
+ if (hasLocalConfig)
86
104
  return { dir, note: null };
87
105
  const roots = discoverRootModules(dir);
88
106
  if (roots.length === 1) {
89
- return { dir: roots[0].dir, note: `no Terraform in the working directory; using the only root module found, ${roots[0].relative}` };
107
+ return { dir: roots[0].dir, note: `no infrastructure code in the working directory; using the only root module found, ${roots[0].relative}` };
90
108
  }
91
109
  if (roots.length > 1) {
92
110
  // Annotations go on their own line: the caller is usually an agent, and a
@@ -94,37 +112,37 @@ function resolveRootModule(dir) {
94
112
  const lines = roots.flatMap((root) => {
95
113
  const notes = [
96
114
  root.environmentHint ? `${root.environmentHint} environment` : "shared stack",
97
- root.initialised ? "initialised" : "run `terraform init` here first",
115
+ root.initialised ? "initialised" : `run \`${tool.executable} init\` here first`,
98
116
  ].join(", ");
99
117
  return [` # ${root.relative} — ${notes}`, ` nexarch ingest-infra --dir ${root.relative}`];
100
118
  });
101
119
  throw new Error([
102
- `This repository has ${roots.length} root modules, each with its own state.`,
103
- ` Every root module is a separate estate, so ingest them one at a time:`,
120
+ `This repository has ${roots.length} ${tool.unitNoun}s, each with its own estate.`,
121
+ ` Each is a separate estate, so ingest them one at a time:`,
104
122
  ...lines,
105
123
  ].join("\n"));
106
124
  }
107
125
  throw new Error([
108
- `No Terraform root module found at or below ${dir}.`,
126
+ `No ${tool.unitNoun} found at or below ${dir}.`,
109
127
  ` Run this inside the repository that provisions the estate, or pass --dir <path> or --state <file>.`,
110
128
  ].join("\n"));
111
129
  }
112
- /** Reads state from a file, or asks Terraform for it when running inside the repo. */
113
- function loadState(statePath, dir) {
130
+ /** Reads state from a file, or asks the IaC tool for it when running inside the repo. */
131
+ function loadState(statePath, dir, tool) {
114
132
  if (statePath) {
115
133
  const full = resolve(statePath);
116
134
  if (!existsSync(full))
117
135
  throw new Error(`State file not found: ${full}`);
118
- return { document: parseTerraformStateBuffer(readFileSync(full)), source: full };
136
+ return { document: parseIacStateBuffer(readFileSync(full)), source: full };
119
137
  }
120
138
  try {
121
- const stdout = execFileSync("terraform", ["show", "-json"], {
139
+ const stdout = execFileSync(tool.executable, [...tool.estateArgs], {
122
140
  cwd: dir,
123
141
  maxBuffer: 256 * 1024 * 1024,
124
142
  encoding: "buffer",
125
143
  stdio: ["ignore", "pipe", "pipe"],
126
144
  });
127
- return { document: parseTerraformStateBuffer(stdout), source: "terraform show -json" };
145
+ return { document: parseIacStateBuffer(stdout), source: `${tool.executable} ${tool.estateArgs.join(" ")}` };
128
146
  }
129
147
  catch (error) {
130
148
  // `error.message` is only ever "Command failed: terraform show -json",
@@ -146,12 +164,12 @@ function loadState(statePath, dir) {
146
164
  .slice(0, 8)
147
165
  .map((line) => ` ${line.trim()}`)
148
166
  .join("\n");
149
- throw new Error(`Could not read Terraform state in ${dir}.\n` +
150
- ` Run inside the infrastructure repo with terraform available, or pass --state <file>.\n` +
151
- `\n terraform said:\n${detail}`);
167
+ throw new Error(`Could not read ${tool.displayName} state in ${dir}.\n` +
168
+ ` Run inside the infrastructure repo with ${tool.executable} available, or pass --state <file>.\n` +
169
+ `\n ${tool.displayName} said:\n${detail}`);
152
170
  }
153
171
  }
154
- function resolveEnvironment(projection, override, dir, rootModuleDir) {
172
+ function resolveEnvironment(projection, override, dir, rootModuleDir, tool) {
155
173
  if (override)
156
174
  return { environment: override.trim().toLowerCase(), how: "--environment flag" };
157
175
  // A root module living in environments/prod states which estate is being read
@@ -164,10 +182,15 @@ function resolveEnvironment(projection, override, dir, rootModuleDir) {
164
182
  if (inferred && inferred.confidence >= 0.6) {
165
183
  return { environment: inferred.environment, how: `inferred from resource tags (${Math.round(inferred.confidence * 100)}% agreement)` };
166
184
  }
185
+ // Guarded, not attempted-and-caught: Ansible has no workspace concept, and
186
+ // shelling out to a binary that will never answer the question is a second's
187
+ // wait and a misleading "not available" in the logs.
167
188
  try {
168
- const workspace = execFileSync("terraform", ["workspace", "show"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
189
+ if (!tool.hasWorkspaces)
190
+ throw new Error("no workspaces");
191
+ const workspace = execFileSync(tool.executable, ["workspace", "show"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
169
192
  if (workspace && workspace !== "default")
170
- return { environment: workspace.toLowerCase(), how: "terraform workspace" };
193
+ return { environment: workspace.toLowerCase(), how: `${tool.id} workspace` };
171
194
  }
172
195
  catch {
173
196
  // Terraform not available; fall through to the error below.
@@ -185,7 +208,7 @@ function resolveEnvironment(projection, override, dir, rootModuleDir) {
185
208
  else if (projection.stats.resourcesProjected > 0)
186
209
  detail.push("none carried an environment or env tag");
187
210
  throw new Error(`Could not determine the environment — ${detail.join("; ")}.\n` +
188
- ` Fix by tagging resources with \`environment\`, selecting a named Terraform workspace, or passing --environment <name>.`);
211
+ ` Fix by tagging resources with \`environment\`, selecting a named ${tool.displayName} workspace, or passing --environment <name>.`);
189
212
  }
190
213
  /** Last non-empty segment of a cloud resource path (ARM id, ARN, self-link) — that resource's name. */
191
214
  function lastPathSegment(armId) {
@@ -194,7 +217,7 @@ function lastPathSegment(armId) {
194
217
  }
195
218
  /** Builds the entity and relationship payloads for the existing upsert contract. */
196
219
  export function buildIngestPayload(params) {
197
- const { projection, environment, platformRef, platformName, catalogue } = params;
220
+ const { projection, environment, platformRef, platformName, catalogue, tool } = params;
198
221
  const environmentRef = `environment:${slug(environment)}`;
199
222
  const entities = [
200
223
  {
@@ -202,15 +225,15 @@ export function buildIngestPayload(params) {
202
225
  entityTypeCode: "platform",
203
226
  name: platformName,
204
227
  description: `Cloud platform hosting the ${environment} environment.`,
205
- attributes: { source: "terraform_ingest" },
228
+ attributes: { source: "iac_ingest", iac_tool: tool.id },
206
229
  },
207
230
  {
208
231
  entityRef: environmentRef,
209
232
  entityTypeCode: "environment",
210
233
  entitySubtypeCode: environmentSubtypeFor(environment),
211
234
  name: environment,
212
- description: `The ${environment} environment, as provisioned by Terraform.`,
213
- attributes: { source: "terraform_ingest", terraform_version: projection.terraformVersion },
235
+ description: `The ${environment} environment, as ${pastVerbFor(tool)} by ${tool.displayName}.`,
236
+ attributes: { source: "iac_ingest", iac_tool: tool.id, iac_tool_version: projection.iacToolVersion },
214
237
  },
215
238
  ];
216
239
  const relationships = [
@@ -242,12 +265,13 @@ export function buildIngestPayload(params) {
242
265
  entityTypeCode: resource.entityTypeCode,
243
266
  ...(resource.entitySubtypeCode ? { entitySubtypeCode: resource.entitySubtypeCode } : {}),
244
267
  name: resource.name ?? resource.address,
245
- description: `${resource.role.replace(/_/g, " ")} provisioned by Terraform (${resource.resourceType}) in ${environment}.`,
268
+ description: `${resource.role.replace(/_/g, " ")} ${pastVerbFor(tool)} by ${tool.displayName} (${resource.resourceType}) in ${environment}.`,
246
269
  attributes: {
247
- source: "terraform_ingest",
248
- terraform_address: resource.address,
249
- terraform_type: resource.resourceType,
250
- terraform_mode: resource.mode,
270
+ source: "iac_ingest",
271
+ iac_tool: tool.id,
272
+ iac_resource_address: resource.address,
273
+ iac_resource_type: resource.resourceType,
274
+ iac_resource_mode: resource.mode,
251
275
  infrastructure_role: resource.role,
252
276
  environment,
253
277
  ...resource.attributes,
@@ -371,7 +395,7 @@ export function buildIngestPayload(params) {
371
395
  entityTypeCode: canonicalTypeCode,
372
396
  name: canonicalTechnologyName(canonicalRef),
373
397
  description: "Cloud service referenced by infrastructure ingestion.",
374
- attributes: { source: "terraform_ingest", canonical: true },
398
+ attributes: { source: "iac_ingest", iac_tool: tool.id, canonical: true },
375
399
  });
376
400
  // A managed service belongs to the cloud that offers it. Only from a
377
401
  // platform_component: part_of does not accept a technology_component
@@ -398,36 +422,136 @@ function chunk(items, size) {
398
422
  out.push(items.slice(i, i + size));
399
423
  return out;
400
424
  }
401
- export async function ingestInfra(args) {
402
- const { statePath, environmentOverride, dir, projectRef, dryRun, asJson } = parseArgs(args);
403
- // Only resolve a root module when Terraform must be run; an explicit --state
425
+ /** Exact MCP inputs used for infrastructure writes; kept inspectable for contract tests. */
426
+ export function buildIngestUpsertPayloads(params) {
427
+ const { entities, relationships, agentContext, policyContext } = params;
428
+ return {
429
+ entityPayloads: chunk(entities, ENTITY_BATCH).map((entities) => ({ entities, agentContext, policyContext })),
430
+ relationshipPayloads: chunk(relationships, ENTITY_BATCH).map((relationships) => ({ relationships, agentContext, policyContext })),
431
+ };
432
+ }
433
+ /**
434
+ * Reads the estate the way this tool exposes it.
435
+ *
436
+ * The two shapes are genuinely different, which is the whole finding of stage 4:
437
+ * Terraform dumps state that is then matched against the resource catalogue,
438
+ * while Ansible enumerates an inventory that has no catalogue at all -- a host
439
+ * is a host. Branching here rather than inside the projector keeps each path
440
+ * readable, and keeps the catalogue fetch off the Ansible path where it would
441
+ * be a wasted round trip.
442
+ */
443
+ async function readEstate(params) {
444
+ const { tool, dir, statePath, asJson } = params;
445
+ if (tool.id === "ansible") {
446
+ const { inventory, source, inventoryPath } = loadAnsibleInventory(statePath, dir, tool);
447
+ return {
448
+ projection: projectAnsibleInventory({ inventory, environment: "unknown" }),
449
+ source,
450
+ workingDir: dir,
451
+ environmentHint: inventoryPath ? environmentFromInventoryPath(inventoryPath) : null,
452
+ catalogue: [],
453
+ };
454
+ }
455
+ // Only resolve a root module when the tool must be run; an explicit --state
404
456
  // file is authoritative and needs no repository at all.
405
457
  let workingDir = dir;
406
458
  if (!statePath) {
407
- const resolved = resolveRootModule(dir);
459
+ const resolved = resolveRootModule(dir, tool);
408
460
  workingDir = resolved.dir;
409
461
  if (resolved.note && !asJson)
410
462
  console.log(` ${resolved.note}`);
411
463
  }
412
- const { document, source } = loadState(statePath, workingDir);
464
+ const { document, source } = loadState(statePath, workingDir, tool);
413
465
  // Fetched once and used for both projection and payload: two different
414
466
  // catalogues within one ingest would produce entities whose attributes and
415
467
  // whose type mapping disagreed about what a resource is.
416
468
  const catalogueResult = await loadIacCatalogue();
417
469
  if (!asJson)
418
470
  console.log(` ${describeCatalogue(catalogueResult)}`);
419
- const catalogue = catalogueResult.entries;
420
- const projection = projectTerraformState({ document, catalogue, environment: "unknown" });
421
- // Terraform emits a valid but empty document when it cannot see state, which
471
+ return {
472
+ projection: projectIacState({ document, catalogue: catalogueResult.entries, environment: "unknown" }),
473
+ source,
474
+ workingDir,
475
+ environmentHint: null,
476
+ catalogue: catalogueResult.entries,
477
+ };
478
+ }
479
+ /**
480
+ * Reads an inventory, either from a path or by asking Ansible to enumerate one.
481
+ *
482
+ * A path is parsed directly rather than passed to `ansible-inventory -i`: it
483
+ * makes the common case work without Ansible installed at all, and the parser
484
+ * handles the same JSON, YAML and INI that Ansible itself accepts.
485
+ */
486
+ function loadAnsibleInventory(inventoryPath, dir, tool) {
487
+ if (inventoryPath) {
488
+ const full = resolve(inventoryPath);
489
+ if (!existsSync(full))
490
+ throw new Error(`Inventory not found: ${full}`);
491
+ return { inventory: parseAnsibleInventory(readFileSync(full, "utf8")), source: full, inventoryPath };
492
+ }
493
+ // No path given: let the repository's own layout choose, so the environment
494
+ // can be read off it. One inventory is unambiguous; several are not, and
495
+ // guessing which environment to ingest is exactly the guess not to make.
496
+ const detection = detectAnsibleProject(dir);
497
+ if (detection.inventoryFiles.length === 1) {
498
+ const relative = detection.inventoryFiles[0];
499
+ const full = join(dir, relative);
500
+ if (existsSync(full) && statSync(full).isFile()) {
501
+ return { inventory: parseAnsibleInventory(readFileSync(full, "utf8")), source: full, inventoryPath: relative };
502
+ }
503
+ }
504
+ if (detection.inventoryFiles.length > 1) {
505
+ const lines = detection.inventoryFiles.map((file) => ` nexarch ingest-infra --inventory ${file}`);
506
+ throw new Error([
507
+ `This repository has ${detection.inventoryFiles.length} inventories, each describing its own estate.`,
508
+ ` Ingest them one at a time:`,
509
+ ...lines,
510
+ ].join("\n"));
511
+ }
512
+ try {
513
+ const stdout = execFileSync(tool.executable, [...tool.estateArgs], {
514
+ cwd: dir,
515
+ maxBuffer: 256 * 1024 * 1024,
516
+ encoding: "buffer",
517
+ stdio: ["ignore", "pipe", "pipe"],
518
+ });
519
+ return {
520
+ inventory: parseAnsibleInventory(stdout.toString("utf8")),
521
+ source: `${tool.executable} ${tool.estateArgs.join(" ")}`,
522
+ inventoryPath: null,
523
+ };
524
+ }
525
+ catch (error) {
526
+ const detail = String(error.stderr ?? "").trim() || error.message;
527
+ throw new Error(`Could not read an Ansible inventory in ${dir}.\n` +
528
+ ` Pass --inventory <file>, or run this where \`${tool.executable}\` can see one.\n` +
529
+ `\n ${tool.executable} said:\n ${detail.split("\n").slice(0, 6).join("\n ")}`);
530
+ }
531
+ }
532
+ export async function ingestInfra(args) {
533
+ const { statePath, environmentOverride, dir, projectRef, dryRun, asJson, tool: requestedTool } = parseArgs(args);
534
+ const tool = iacToolOrThrow(dir, requestedTool);
535
+ const { projection, source, workingDir, environmentHint, catalogue } = await readEstate({ tool, dir, statePath, asJson });
536
+ // Both tools emit a valid but empty document when they cannot see state,
422
537
  // is a different problem from anything downstream and deserves its own
423
538
  // message rather than surfacing later as a confusing tagging complaint.
539
+ // An empty estate needs the diagnosis that fits the tool. Telling someone
540
+ // holding an inventory to run `terraform init` is worse than saying nothing.
541
+ if (projection.stats.resourcesSeen === 0 && !tool.provisions) {
542
+ throw new Error(`No hosts found in the ${tool.unitNoun} (read from ${source}).\n` +
543
+ ` An inventory with no hosts describes no estate. Establish which, in this order:\n` +
544
+ ` 1. run \`${tool.executable} ${tool.estateArgs.join(" ")}\` here — empty output confirms it\n` +
545
+ ` 2. check the inventory is the one you meant, with --inventory <file>\n` +
546
+ ` 3. a dynamic inventory plugin may need credentials this shell does not have`);
547
+ }
424
548
  if (projection.stats.resourcesSeen === 0) {
425
- throw new Error(`No resources found in Terraform state (read from ${source}).\n` +
426
- ` Terraform ran but reported an empty state. Establish which, in this order:\n` +
427
- ` 1. run \`terraform state list\` in ${workingDir} — empty output confirms there is no state here\n` +
428
- ` 2. if there is no .terraform directory, run \`terraform init\`, then retry\n` +
429
- ` 3. run \`terraform workspace list\` and select the one that was applied\n` +
430
- ` 4. if this repository has several root modules, ingest each separately with --dir\n` +
549
+ throw new Error(`No resources found in ${tool.displayName} state (read from ${source}).\n` +
550
+ ` ${tool.displayName} ran but reported an empty state. Establish which, in this order:\n` +
551
+ ` 1. run \`${tool.executable} state list\` in ${workingDir} — empty output confirms there is no state here\n` +
552
+ ` 2. if there is no .terraform directory, run \`${tool.executable} init\`, then retry\n` +
553
+ ` 3. run \`${tool.executable} workspace list\` and select the one that was applied\n` +
554
+ ` 4. if this repository has several ${tool.unitNoun}s, ingest each separately with --dir\n` +
431
555
  ` The estate may also simply not have been applied yet.`);
432
556
  }
433
557
  // Nothing recognised is a coverage problem, and has to be said before the
@@ -436,7 +560,9 @@ export async function ingestInfra(args) {
436
560
  // environment — 0 of those carried any tags", which sends the reader off to
437
561
  // tag resources that would still not be ingested afterwards. The environment
438
562
  // is the next question only once there is something to attach it to.
439
- if (projection.stats.resourcesProjected === 0) {
563
+ // Ansible has no resource catalogue: every host is projected by definition,
564
+ // so a catalogue-coverage complaint here would be nonsense.
565
+ if (tool.provisions && projection.stats.resourcesProjected === 0) {
440
566
  const seen = projection.unknownTypes
441
567
  .slice(0, 12)
442
568
  .map((entry) => ` ${String(entry.count).padStart(3)} ${entry.resourceType}`)
@@ -446,7 +572,9 @@ export async function ingestInfra(args) {
446
572
  `\n Resource types found here:\n${seen}\n` +
447
573
  `\n These need adding to the platform catalogue before this root can be ingested.`);
448
574
  }
449
- const { environment, how } = resolveEnvironment(projection, environmentOverride, workingDir, statePath ? null : workingDir);
575
+ const { environment, how } = environmentHint && !environmentOverride
576
+ ? { environment: environmentHint, how: `${tool.unitNoun} path` }
577
+ : resolveEnvironment(projection, environmentOverride, workingDir, statePath ? null : workingDir, tool);
450
578
  projection.environment = environment;
451
579
  // Detected from what is actually in state, not assumed: an estate is read
452
580
  // once and the provider it is on follows from that, the same way the
@@ -458,8 +586,12 @@ export async function ingestInfra(args) {
458
586
  ...projection.resources.map((r) => r.resourceType),
459
587
  ...projection.unknownTypes.map((u) => u.resourceType),
460
588
  ]);
461
- let platformRef = detectedPlatform?.fallbackRef ?? "platform:unknown";
462
- let platformName = detectedPlatform?.fallbackName ?? "Unrecognised cloud platform";
589
+ // A tool that does not provision genuinely does not know what the hosts run
590
+ // on -- an inventory says `web-01`, not whether it is EC2 or a machine under
591
+ // someone's desk. "Unrecognised cloud platform" would be a worse answer than
592
+ // naming what we do know, which is that these are hosts we configure.
593
+ let platformRef = detectedPlatform?.fallbackRef ?? (tool.provisions ? "platform:unknown" : "platform:managed_hosts");
594
+ let platformName = detectedPlatform?.fallbackName ?? (tool.provisions ? "Unrecognised cloud platform" : "Managed hosts");
463
595
  if (!dryRun && detectedPlatform) {
464
596
  try {
465
597
  const raw = await callMcpTool("nexarch_resolve_reference", { names: detectedPlatform.resolveNames });
@@ -494,13 +626,13 @@ export async function ingestInfra(args) {
494
626
  // Coverage reporting must never fail an ingest: the estate is the point.
495
627
  }
496
628
  }
497
- const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName, catalogue });
629
+ const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName, catalogue, tool });
498
630
  const report = {
499
631
  ok: true,
500
632
  source,
501
633
  environment,
502
634
  environmentSource: how,
503
- terraformVersion: projection.terraformVersion,
635
+ iacToolVersion: projection.iacToolVersion,
504
636
  resourcesSeen: projection.stats.resourcesSeen,
505
637
  resourcesProjected: projection.stats.resourcesProjected,
506
638
  attributesDropped: {
@@ -536,13 +668,14 @@ export async function ingestInfra(args) {
536
668
  provider: "n/a",
537
669
  };
538
670
  const policyContext = { policyBundleHash, alignmentSummary: { score: 1, violations: [], waivers: [] } };
671
+ const upsertPayloads = buildIngestUpsertPayloads({ entities, relationships, agentContext, policyContext });
539
672
  let failed = 0;
540
- for (const batch of chunk(entities, ENTITY_BATCH)) {
541
- const raw = await callMcpTool("nexarch_upsert_entities", { entities: batch, agentContext, policyContext });
673
+ for (const payload of upsertPayloads.entityPayloads) {
674
+ const raw = await callMcpTool("nexarch_upsert_entities", payload);
542
675
  failed += (readResult(raw).summary?.failed ?? 0);
543
676
  }
544
- for (const batch of chunk(relationships, ENTITY_BATCH)) {
545
- const raw = await callMcpTool("nexarch_upsert_relationships", { relationships: batch, agentContext, policyContext });
677
+ for (const payload of upsertPayloads.relationshipPayloads) {
678
+ const raw = await callMcpTool("nexarch_upsert_relationships", payload);
546
679
  failed += (readResult(raw).summary?.failed ?? 0);
547
680
  }
548
681
  report.written = { entities: entities.length, relationships: relationships.length, failed };
@@ -554,9 +687,9 @@ export async function ingestInfra(args) {
554
687
  function printHuman(report, entityCount, relationshipCount) {
555
688
  const dropped = report.attributesDropped;
556
689
  console.log(`\nInfrastructure ingest — ${report.environment} (${report.environmentSource})`);
557
- console.log(` Source: ${report.source}${report.terraformVersion ? ` · terraform ${report.terraformVersion}` : ""}`);
690
+ console.log(` Source: ${report.source}${report.iacToolVersion ? ` · ${report.iacTool} ${report.iacToolVersion}` : ""}`);
558
691
  console.log(` Projected ${report.resourcesProjected} of ${report.resourcesSeen} resources to the contract surface.`);
559
- console.log(` Dropped ${dropped.notAllowlisted} attributes not on the allowlist, ${dropped.flaggedSensitive} flagged sensitive by Terraform.`);
692
+ console.log(` Dropped ${dropped.notAllowlisted} attributes not on the allowlist, ${dropped.flaggedSensitive} flagged sensitive by the tool.`);
560
693
  const unknown = report.unknownTypes;
561
694
  if (unknown.length > 0) {
562
695
  console.log(`\n Not yet in the catalogue (reported so coverage can grow):`);