nexarch 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -0,0 +1,414 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
2
+ import { join, sep } from "path";
3
+ const SCAN_DEPTH = 8;
4
+ const IGNORED_DIRS = new Set(["node_modules", ".git", ".terraform", "dist", "build", ".next"]);
5
+ /**
6
+ * Path segments whose contents demonstrate Terraform rather than run it.
7
+ *
8
+ * An `examples/complete` directory configures a provider and looks exactly like
9
+ * an estate root by every local signal — which is why terraform-aws-vpc
10
+ * reported thirteen of them as environments while missing the modules that are
11
+ * the point of the repository. Worse than missing the real architecture: the
12
+ * examples are mutually exclusive demonstrations, so graphing them together
13
+ * describes an estate that has never existed anywhere.
14
+ */
15
+ const NON_ESTATE_SEGMENT = /(^|[\\/])(examples?|docs?|test|tests|fixtures?|testdata|_example|sample|samples)([\\/]|$)/i;
16
+ function collectTerraformFiles(dir, depth = 0, out = []) {
17
+ if (depth > SCAN_DEPTH)
18
+ return out;
19
+ let entries;
20
+ try {
21
+ entries = readdirSync(dir);
22
+ }
23
+ catch {
24
+ return out;
25
+ }
26
+ for (const entry of entries) {
27
+ if (IGNORED_DIRS.has(entry))
28
+ continue;
29
+ const full = join(dir, entry);
30
+ let isDir = false;
31
+ try {
32
+ isDir = statSync(full).isDirectory();
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ if (isDir)
38
+ collectTerraformFiles(full, depth + 1, out);
39
+ else if (entry.endsWith(".tf"))
40
+ out.push(full);
41
+ }
42
+ return out;
43
+ }
44
+ function detectCiSystem(dir) {
45
+ if (existsSync(join(dir, ".gitlab-ci.yml")))
46
+ return { ciSystem: "gitlab", ciFile: ".gitlab-ci.yml" };
47
+ if (existsSync(join(dir, ".github", "workflows")))
48
+ return { ciSystem: "github", ciFile: ".github/workflows" };
49
+ const seen = [];
50
+ const visit = (current, depth) => {
51
+ if (depth > 3 || seen.length > 0)
52
+ return;
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(current);
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ for (const entry of entries) {
61
+ if (IGNORED_DIRS.has(entry))
62
+ continue;
63
+ const full = join(current, entry);
64
+ let isDir = false;
65
+ try {
66
+ isDir = statSync(full).isDirectory();
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (isDir) {
72
+ visit(full, depth + 1);
73
+ continue;
74
+ }
75
+ const relative = full.slice(dir.length + 1).split(sep).join("/");
76
+ if (/azure-pipelines.*\.ya?ml$/i.test(entry)) {
77
+ seen.push({ ciSystem: "azure-pipelines", ciFile: relative });
78
+ return;
79
+ }
80
+ if (entry === ".gitlab-ci.yml") {
81
+ seen.push({ ciSystem: "gitlab", ciFile: relative });
82
+ return;
83
+ }
84
+ }
85
+ };
86
+ visit(dir, 0);
87
+ return seen[0] ?? { ciSystem: null, ciFile: null };
88
+ }
89
+ /** Collects reusable module directories wherever they live, not only at the root. */
90
+ function collectModuleDirectories(dir, depth = 0, out = []) {
91
+ if (depth > 3)
92
+ return out;
93
+ let entries;
94
+ try {
95
+ entries = readdirSync(dir);
96
+ }
97
+ catch {
98
+ return out;
99
+ }
100
+ for (const entry of entries) {
101
+ if (IGNORED_DIRS.has(entry))
102
+ continue;
103
+ const full = join(dir, entry);
104
+ try {
105
+ if (!statSync(full).isDirectory())
106
+ continue;
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (entry === "modules") {
112
+ // The children of a modules directory are the reusable building blocks:
113
+ // network, database, keyvault, and so on — the vocabulary of the estate.
114
+ try {
115
+ for (const child of readdirSync(full)) {
116
+ if (statSync(join(full, child)).isDirectory())
117
+ out.push(child);
118
+ }
119
+ }
120
+ catch {
121
+ // Unreadable modules directory is not fatal.
122
+ }
123
+ continue;
124
+ }
125
+ collectModuleDirectories(full, depth + 1, out);
126
+ }
127
+ return out;
128
+ }
129
+ /**
130
+ * Recognises a repository that publishes reusable modules rather than running an estate.
131
+ *
132
+ * The Terraform registry convention is a module at the repository root —
133
+ * `main.tf` with `variables.tf` and `outputs.tf` beside it — deliberately
134
+ * configuring no provider and no backend, because both are supplied by whoever
135
+ * calls it. Every signal that identifies an estate is therefore absent by
136
+ * design, and the signals that *are* present belong to the examples directory.
137
+ *
138
+ * This matters more than tidiness. terraform-aws-vpc has no estate to ingest:
139
+ * asking it for one and getting thirteen mutually exclusive demonstrations is
140
+ * worse than getting nothing, because nothing is honest.
141
+ */
142
+ function detectPublishedModules(dir) {
143
+ const rootFiles = (() => {
144
+ try {
145
+ return readdirSync(dir);
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ })();
151
+ const hasRootModule = rootFiles.includes("variables.tf") && rootFiles.includes("outputs.tf") && rootFiles.some((f) => f.endsWith(".tf") && f !== "variables.tf" && f !== "outputs.tf");
152
+ const modules = [];
153
+ const modulesDir = join(dir, "modules");
154
+ try {
155
+ for (const child of readdirSync(modulesDir)) {
156
+ const full = join(modulesDir, child);
157
+ if (statSync(full).isDirectory() && readdirSync(full).some((f) => f.endsWith(".tf")))
158
+ modules.push(child);
159
+ }
160
+ }
161
+ catch {
162
+ // No modules directory; a single-module repository is still a library.
163
+ }
164
+ return { isLibrary: hasRootModule, modules: modules.sort() };
165
+ }
166
+ const ROOT_SCAN_DEPTH = 4;
167
+ const ENVIRONMENT_DIR = /^(dev|development|test|qa|uat|sit|stag|staging|preprod|pre-prod|prod|production|live|dr)$/i;
168
+ /**
169
+ * Infers an environment from a root module's path.
170
+ *
171
+ * A directory called `environments/prod` is a stronger statement about which
172
+ * environment is being ingested than anything inside the state, and it is
173
+ * available before Terraform is run at all.
174
+ */
175
+ export function environmentFromPath(relativePath) {
176
+ const segments = relativePath.split(/[\\/]+/).filter(Boolean);
177
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
178
+ if (ENVIRONMENT_DIR.test(segments[i]))
179
+ return segments[i].toLowerCase();
180
+ }
181
+ return null;
182
+ }
183
+ /**
184
+ * Finds the root modules in a repository.
185
+ *
186
+ * Real estates are not one directory with one state. A repository commonly
187
+ * carries a root module per environment plus separate stacks for shared
188
+ * services, each with its own state, so assuming the current directory is
189
+ * *the* root module fails on the first serious layout it meets.
190
+ *
191
+ * Distinguishing a root from a shared module is the crux: both contain `.tf`
192
+ * files and both may declare `required_providers`. A root is what configures a
193
+ * backend or a provider, is initialised, carries tfvars, or lives under a
194
+ * conventional stacks directory — and never lives under `modules/`.
195
+ */
196
+ export function discoverRootModules(repoDir) {
197
+ const found = [];
198
+ const visit = (dir, relative, depth) => {
199
+ if (depth > ROOT_SCAN_DEPTH)
200
+ return;
201
+ let entries;
202
+ try {
203
+ entries = readdirSync(dir);
204
+ }
205
+ catch {
206
+ return;
207
+ }
208
+ const tfFiles = entries.filter((entry) => entry.endsWith(".tf"));
209
+ const isUnderModules = /(^|[\\/])modules([\\/]|$)/i.test(relative);
210
+ const isDemonstration = NON_ESTATE_SEGMENT.test(relative);
211
+ if (tfFiles.length > 0 && !isUnderModules && !isDemonstration) {
212
+ const signals = [];
213
+ let hasBackend = false;
214
+ let hasLocalBackend = false;
215
+ let hasProvider = false;
216
+ for (const file of tfFiles.slice(0, 25)) {
217
+ let content = "";
218
+ try {
219
+ content = readFileSync(join(dir, file), "utf8");
220
+ }
221
+ catch {
222
+ continue;
223
+ }
224
+ const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
225
+ if (backendMatch) {
226
+ hasBackend = true;
227
+ if (backendMatch[1] === "local")
228
+ hasLocalBackend = true;
229
+ }
230
+ // A provider *configuration*, not a required_providers declaration —
231
+ // shared modules declare requirements but must not configure providers.
232
+ if (/^\s*provider\s+"[a-z0-9_]+"\s*\{/m.test(content))
233
+ hasProvider = true;
234
+ }
235
+ // A `.terraform` directory only proves provider plugins were downloaded.
236
+ // The backend — the thing `terraform show` needs — is initialised when
237
+ // `.terraform/terraform.tfstate` records its configuration. Treating the
238
+ // directory as proof reports a root as ready, the caller runs ingest, and
239
+ // Terraform answers "Backend initialization required" from somewhere the
240
+ // caller was told not to expect it.
241
+ const hasPluginDir = existsSync(join(dir, ".terraform"));
242
+ const initialised = existsSync(join(dir, ".terraform", "terraform.tfstate"));
243
+ const hasTfvars = entries.some((entry) => entry.endsWith(".tfvars") || entry.endsWith(".tfvars.json"));
244
+ const environmentHint = environmentFromPath(relative);
245
+ const underStacksDir = /(^|[\\/])(environments|envs|stacks|live)([\\/]|$)/i.test(relative);
246
+ if (hasBackend)
247
+ signals.push(hasLocalBackend ? "local backend" : "remote backend");
248
+ if (hasProvider)
249
+ signals.push("provider configuration");
250
+ if (initialised)
251
+ signals.push("initialised");
252
+ else if (hasPluginDir)
253
+ signals.push("providers downloaded, backend not initialised");
254
+ if (hasTfvars)
255
+ signals.push("tfvars");
256
+ if (underStacksDir)
257
+ signals.push("stacks directory");
258
+ // A provider block alone no longer qualifies. It is the one signal an
259
+ // example shares with a real estate, and treating it as sufficient is
260
+ // what let demonstration fixtures in. What distinguishes an estate is
261
+ // evidence that state exists or is configured somewhere: a backend, an
262
+ // initialised working directory, or a conventional stacks layout.
263
+ const isEstateRoot = hasBackend || initialised || hasPluginDir || (underStacksDir && (hasTfvars || Boolean(environmentHint)));
264
+ if (isEstateRoot) {
265
+ found.push({ dir, relative: relative || ".", signals, environmentHint, initialised, usesLocalBackend: hasLocalBackend });
266
+ // A root module's subdirectories are its own modules, not further roots.
267
+ return;
268
+ }
269
+ }
270
+ for (const entry of entries) {
271
+ if (IGNORED_DIRS.has(entry))
272
+ continue;
273
+ const full = join(dir, entry);
274
+ try {
275
+ if (!statSync(full).isDirectory())
276
+ continue;
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ visit(full, relative ? `${relative}/${entry}` : entry, depth + 1);
282
+ }
283
+ };
284
+ visit(repoDir, "", 0);
285
+ return found.sort((a, b) => a.relative.localeCompare(b.relative));
286
+ }
287
+ export function detectInfrastructureProject(dir) {
288
+ const files = collectTerraformFiles(dir);
289
+ const signals = [];
290
+ if (files.length > 0)
291
+ signals.push(`${files.length} Terraform file${files.length === 1 ? "" : "s"}`);
292
+ if (existsSync(join(dir, ".terraform.lock.hcl")))
293
+ signals.push("provider lock file");
294
+ if (existsSync(join(dir, ".terraform")))
295
+ signals.push("initialised working directory");
296
+ let hasRemoteBackend = false;
297
+ let hasProviderBlock = false;
298
+ // The cap guards against a pathological repo, not against a large one: now
299
+ // that the scan reaches the whole tree, a low cap would read only whichever
300
+ // subtree the walk happened to enter first and miss the backend entirely.
301
+ for (const file of files.slice(0, 400)) {
302
+ let content = "";
303
+ try {
304
+ content = readFileSync(file, "utf8");
305
+ }
306
+ catch {
307
+ continue;
308
+ }
309
+ // `backend "local"` marks a root module but is emphatically not remote
310
+ // state — a bootstrap stack uses it precisely because it creates the remote
311
+ // backend everything else then uses.
312
+ const backendMatch = content.match(/\bbackend\s+"([a-z0-9_]+)"\s*\{/);
313
+ if (backendMatch && backendMatch[1] !== "local")
314
+ hasRemoteBackend = true;
315
+ if (/\bprovider\s+"[a-z0-9_]+"\s*\{/.test(content))
316
+ hasProviderBlock = true;
317
+ }
318
+ if (hasRemoteBackend)
319
+ signals.push("remote state backend");
320
+ if (hasProviderBlock)
321
+ signals.push("provider configuration");
322
+ const moduleDirectories = [...new Set(collectModuleDirectories(dir))].sort();
323
+ if (moduleDirectories.length > 0)
324
+ signals.push(`${moduleDirectories.length} reusable modules`);
325
+ // One stray .tf file inside an application repo is not an infrastructure
326
+ // repo. Requiring corroboration keeps the classification from hijacking
327
+ // onboarding for a project that merely ships a snippet of Terraform.
328
+ // Deep layouts keep every .tf file below the two-level scan above, so
329
+ // discovery is consulted as well: a repository whose roots live in
330
+ // infra/environments/<env> is unmistakably an infrastructure repository even
331
+ // though its top two levels contain no Terraform at all.
332
+ const rootModules = discoverRootModules(dir);
333
+ if (rootModules.length > 0) {
334
+ signals.push(`${rootModules.length} root module${rootModules.length === 1 ? "" : "s"}`);
335
+ }
336
+ const library = detectPublishedModules(dir);
337
+ // An estate is claimed only by root modules that survived the tightened
338
+ // acceptance above. A repository with a root module and no estate roots is
339
+ // publishing modules, not running anything.
340
+ const repoKind = rootModules.length > 0 ? "estate" : library.isLibrary ? "module_library" : "unknown";
341
+ if (repoKind === "module_library") {
342
+ signals.push(library.modules.length > 0 ? `publishes ${library.modules.length + 1} modules` : "publishes a reusable module");
343
+ }
344
+ const isInfrastructure = rootModules.length > 0 ||
345
+ repoKind === "module_library" ||
346
+ (files.length > 0 && (files.length >= 3 || hasRemoteBackend || hasProviderBlock || moduleDirectories.length > 0));
347
+ return {
348
+ isInfrastructure,
349
+ rootModules,
350
+ signals,
351
+ iacFileCount: files.length,
352
+ hasRemoteBackend,
353
+ hasProviderBlock,
354
+ moduleDirectories,
355
+ repoKind,
356
+ publishedModules: repoKind === "module_library" ? library.modules : [],
357
+ ...detectCiSystem(dir),
358
+ };
359
+ }
360
+ /**
361
+ * The pipeline step to add, for whichever CI system the repo already uses.
362
+ * Onboarding hands this over rather than pointing at documentation: an
363
+ * ingestion command nobody wires up is a graph that lies within a week.
364
+ */
365
+ export function ciSnippetFor(ciSystem, ciFile = null, executable = "terraform") {
366
+ if (ciSystem === "github") {
367
+ return {
368
+ file: ".github/workflows/terraform.yml (in the apply job)",
369
+ snippet: [
370
+ " - name: Sync architecture graph",
371
+ " run: |",
372
+ " terraform show -json > tfstate.json",
373
+ " npx nexarch@latest ingest-infra --state tfstate.json",
374
+ " env:",
375
+ " NEXARCH_TOKEN: ${{ secrets.NEXARCH_TOKEN }}",
376
+ ].join("\n"),
377
+ };
378
+ }
379
+ if (ciSystem === "azure-pipelines") {
380
+ return {
381
+ file: `${ciFile ?? "azure-pipelines.yml"} (after the apply step)`,
382
+ snippet: [
383
+ " - script: |",
384
+ " terraform show -json > tfstate.json",
385
+ " npx nexarch@latest ingest-infra --state tfstate.json",
386
+ " displayName: Sync architecture graph",
387
+ " env:",
388
+ " NEXARCH_TOKEN: $(NEXARCH_TOKEN)",
389
+ ].join("\n"),
390
+ };
391
+ }
392
+ if (ciSystem === "gitlab") {
393
+ return {
394
+ file: ciFile ?? ".gitlab-ci.yml",
395
+ snippet: [
396
+ "nexarch-ingest:",
397
+ " stage: .post",
398
+ " script:",
399
+ " - terraform show -json > tfstate.json",
400
+ " - npx nexarch@latest ingest-infra --state tfstate.json",
401
+ " variables:",
402
+ " NEXARCH_TOKEN: $NEXARCH_TOKEN",
403
+ ].join("\n"),
404
+ };
405
+ }
406
+ // Nothing was detected. Handing over a GitLab job here — the previous
407
+ // behaviour — states as fact something never observed, and a team on another
408
+ // CI system has to work out why the advice does not fit. The two commands are
409
+ // the part that is actually known; the wrapper is not.
410
+ return {
411
+ file: "your IaC pipeline, after the apply step",
412
+ snippet: [`${executable} show -json > tfstate.json`, "npx nexarch@latest ingest-infra --state tfstate.json"].join("\n"),
413
+ };
414
+ }