@meru454545/nexus-modscript-composer 2.0.12

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,1029 @@
1
+ import { execSync } from "node:child_process";
2
+ import { writeFileSync, unlinkSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { getShellcheckPath } from "./shellcheck-bin.js";
6
+ import { validateHereStringTerminators } from "./composer.js";
7
+ import { createLogger } from "./logger.js";
8
+ const log = createLogger("validator");
9
+ // The env vars that L2 writes into the env file and L1 reads at startup.
10
+ // This is the contract between the two layers.
11
+ const NEXUS_ENV_VARS = [
12
+ "NEXUS_ICEBERG_ENDPOINT",
13
+ "NEXUS_AGENT_ID",
14
+ "NEXUS_AUTH_TOKEN",
15
+ "NEXUS_DRY_RUN",
16
+ "NEXUS_MAX_PARALLEL",
17
+ ];
18
+ function createResult() {
19
+ return { valid: true, errors: [], warnings: [] };
20
+ }
21
+ function addError(result, msg) {
22
+ result.errors.push(msg);
23
+ result.valid = false;
24
+ }
25
+ function addWarning(result, msg) {
26
+ result.warnings.push(msg);
27
+ }
28
+ function mergeResults(...results) {
29
+ const merged = createResult();
30
+ for (const r of results) {
31
+ merged.errors.push(...r.errors);
32
+ merged.warnings.push(...r.warnings);
33
+ }
34
+ merged.valid = merged.errors.length === 0;
35
+ return merged;
36
+ }
37
+ // ── Shared checks ──────────────────────────────────────────────────────────
38
+ function checkUnreplacedPlaceholders(script, label) {
39
+ const result = createResult();
40
+ const matches = script.match(/\{\{[A-Z_]+\}\}/g);
41
+ if (matches) {
42
+ const unique = [...new Set(matches)];
43
+ addError(result, `${label} contains unreplaced template placeholders: ${unique.join(", ")}`);
44
+ }
45
+ return result;
46
+ }
47
+ // ── L1 Validation ──────────────────────────────────────────────────────────
48
+ function validateL1Linux(script, moduleNames) {
49
+ const result = createResult();
50
+ // Shebang
51
+ if (!script.startsWith("#!/bin/bash")) {
52
+ addError(result, "L1 Linux: missing #!/bin/bash shebang");
53
+ }
54
+ // Shell safety flags
55
+ if (!script.includes("set -uo pipefail")) {
56
+ addError(result, "L1 Linux: missing 'set -uo pipefail' safety flags");
57
+ }
58
+ // Env var reads — L1 must consume every var the L2 env file provides
59
+ for (const envVar of NEXUS_ENV_VARS) {
60
+ if (!script.includes(envVar)) {
61
+ addError(result, `L1 Linux: does not read env var ${envVar}`);
62
+ }
63
+ }
64
+ // Required guard — L1 must reject missing critical env vars
65
+ if (!script.includes('-z "$ICEBERG_ENDPOINT"')) {
66
+ addWarning(result, "L1 Linux: missing empty-check guard for ICEBERG_ENDPOINT");
67
+ }
68
+ if (!script.includes('-z "$AGENT_ID"')) {
69
+ addWarning(result, "L1 Linux: missing empty-check guard for AGENT_ID");
70
+ }
71
+ if (!script.includes('-z "$AUTH_TOKEN"')) {
72
+ addWarning(result, "L1 Linux: missing empty-check guard for AUTH_TOKEN");
73
+ }
74
+ // L0 function definitions — each module must have its collector function
75
+ for (const mod of moduleNames) {
76
+ const fnName = `nexus_collect_${mod}`;
77
+ if (!script.includes(fnName)) {
78
+ addError(result, `L1 Linux: missing L0 function definition '${fnName}' for module '${mod}'`);
79
+ }
80
+ }
81
+ // Module array — the orchestration loop must list all modules
82
+ for (const mod of moduleNames) {
83
+ if (!script.includes(`"${mod}"`)) {
84
+ addError(result, `L1 Linux: module '${mod}' not found in modules array`);
85
+ }
86
+ }
87
+ // Core functions
88
+ if (!script.includes("run_all_modules")) {
89
+ addError(result, "L1 Linux: missing 'run_all_modules' entry point");
90
+ }
91
+ if (!script.includes("_push_to_iceberg")) {
92
+ addError(result, "L1 Linux: missing '_push_to_iceberg' function");
93
+ }
94
+ if (!script.includes("_run_module")) {
95
+ addError(result, "L1 Linux: missing '_run_module' function");
96
+ }
97
+ if (!script.includes("_build_envelope")) {
98
+ addError(result, "L1 Linux: missing '_build_envelope' function — L0 log/result records would never be folded into a payload");
99
+ }
100
+ if (!script.includes("_json_validate")) {
101
+ addWarning(result, "L1 Linux: missing '_json_validate' — output won't be checked before push");
102
+ }
103
+ if (!script.includes(".ndjson")) {
104
+ addWarning(result, "L1 Linux: no '.ndjson' capture file — L0 record streams may not be captured line-wise");
105
+ }
106
+ if (!script.includes("_interrupt_and_flush") || !/trap\s+_interrupt_and_flush\s+TERM/.test(script)) {
107
+ addError(result, "L1 Linux: missing TERM timeout flush handler — partial L0 records would be lost when systemd times out");
108
+ }
109
+ // L1 must self-redirect output now that the L2 unit no longer uses
110
+ // StandardOutput=append: (which was removed for systemd 228 compatibility).
111
+ // Without this, log lines from L1 vanish into the journal on old systemd.
112
+ if (!/exec\s+>>.*NEXUS_SERVICE_NAME/.test(script)) {
113
+ addError(result, "L1 Linux: missing 'exec >> ...${NEXUS_SERVICE_NAME}...' redirect — L1 logs will be lost on systemd < 240");
114
+ }
115
+ return result;
116
+ }
117
+ function validateL1Aix(script, moduleNames) {
118
+ const result = createResult();
119
+ // Shebang — AIX default ksh (ksh88)
120
+ if (!script.startsWith("#!/bin/ksh")) {
121
+ addError(result, "L1 AIX: missing #!/bin/ksh shebang");
122
+ }
123
+ // Shell safety flags — ksh88 has no `pipefail`, so only `set -u` is required.
124
+ if (!/^set -u\b/m.test(script)) {
125
+ addError(result, "L1 AIX: missing 'set -u' safety flag");
126
+ }
127
+ // Env var reads — L1 must consume every var the L2 env file provides
128
+ for (const envVar of NEXUS_ENV_VARS) {
129
+ if (!script.includes(envVar)) {
130
+ addError(result, `L1 AIX: does not read env var ${envVar}`);
131
+ }
132
+ }
133
+ // Required guard — L1 must reject missing critical env vars
134
+ if (!script.includes('-z "$ICEBERG_ENDPOINT"')) {
135
+ addWarning(result, "L1 AIX: missing empty-check guard for ICEBERG_ENDPOINT");
136
+ }
137
+ if (!script.includes('-z "$AGENT_ID"')) {
138
+ addWarning(result, "L1 AIX: missing empty-check guard for AGENT_ID");
139
+ }
140
+ if (!script.includes('-z "$AUTH_TOKEN"')) {
141
+ addWarning(result, "L1 AIX: missing empty-check guard for AUTH_TOKEN");
142
+ }
143
+ // L0 function definitions — each module must have its collector function
144
+ for (const mod of moduleNames) {
145
+ const fnName = `nexus_collect_${mod}`;
146
+ if (!script.includes(fnName)) {
147
+ addError(result, `L1 AIX: missing L0 function definition '${fnName}' for module '${mod}'`);
148
+ }
149
+ }
150
+ // Module array — the orchestration loop must list all modules
151
+ for (const mod of moduleNames) {
152
+ if (!script.includes(`"${mod}"`)) {
153
+ addError(result, `L1 AIX: module '${mod}' not found in modules array`);
154
+ }
155
+ }
156
+ // ksh88 array population must use `set -A` (no bash `arr=(...)` form)
157
+ if (!/\bset -A\b/.test(script)) {
158
+ addError(result, "L1 AIX: missing 'set -A' module array — ksh88 does not support bash array assignment");
159
+ }
160
+ // Core functions
161
+ if (!script.includes("run_all_modules")) {
162
+ addError(result, "L1 AIX: missing 'run_all_modules' entry point");
163
+ }
164
+ if (!script.includes("_push_to_iceberg")) {
165
+ addError(result, "L1 AIX: missing '_push_to_iceberg' function");
166
+ }
167
+ if (!script.includes("_run_module")) {
168
+ addError(result, "L1 AIX: missing '_run_module' function");
169
+ }
170
+ if (!script.includes("_build_envelope")) {
171
+ addError(result, "L1 AIX: missing '_build_envelope' function — L0 log/result records would never be folded into a payload");
172
+ }
173
+ if (!script.includes("_json_validate")) {
174
+ addWarning(result, "L1 AIX: missing '_json_validate' — output won't be checked before push");
175
+ }
176
+ if (!script.includes(".ndjson")) {
177
+ addWarning(result, "L1 AIX: no '.ndjson' capture file — L0 record streams may not be captured line-wise");
178
+ }
179
+ if (!script.includes("_interrupt_and_flush") || !/trap\s+_interrupt_and_flush\s+TERM/.test(script)) {
180
+ addError(result, "L1 AIX: missing TERM timeout flush handler — partial L0 records would be lost when the wrapper watchdog fires");
181
+ }
182
+ // ksh88 incompatibilities that would silently break on AIX /bin/ksh.
183
+ // These are why dev-box `ksh -n` (ksh93) is not enough — ksh93 accepts them.
184
+ for (const err of detectKsh88Incompatibilities(script, "L1 AIX")) {
185
+ addError(result, err);
186
+ }
187
+ // L1 self-redirects its own logs (kept for manual debug runs; the SRC
188
+ // wrapper daemon also redirects, so this is belt-and-suspenders).
189
+ if (!/exec\s+>>.*NEXUS_SERVICE_NAME/.test(script)) {
190
+ addError(result, "L1 AIX: missing 'exec >> ...${NEXUS_SERVICE_NAME}...' redirect — L1 logs will be lost");
191
+ }
192
+ return result;
193
+ }
194
+ function validateL1Windows(script, moduleNames) {
195
+ const result = createResult();
196
+ // Error preference
197
+ if (!script.includes("$ErrorActionPreference")) {
198
+ addError(result, "L1 Windows: missing $ErrorActionPreference setting");
199
+ }
200
+ // Env var reads
201
+ for (const envVar of NEXUS_ENV_VARS) {
202
+ if (!script.includes(`$env:${envVar}`)) {
203
+ addError(result, `L1 Windows: does not read env var $env:${envVar}`);
204
+ }
205
+ }
206
+ // Required guard
207
+ if (!script.includes("-not $IcebergEndpoint") ||
208
+ !script.includes("-not $AgentId") ||
209
+ !script.includes("-not $AuthToken")) {
210
+ addWarning(result, "L1 Windows: missing empty-check guard for required parameters");
211
+ }
212
+ // Module array — each module must appear in the @() array
213
+ for (const mod of moduleNames) {
214
+ if (!script.includes(`"${mod}"`)) {
215
+ addError(result, `L1 Windows: module '${mod}' not found in modules array`);
216
+ }
217
+ }
218
+ // Core functions
219
+ if (!script.includes("Invoke-AllModules")) {
220
+ addError(result, "L1 Windows: missing 'Invoke-AllModules' entry point");
221
+ }
222
+ if (!script.includes("Push-ToIceberg")) {
223
+ addError(result, "L1 Windows: missing 'Push-ToIceberg' function");
224
+ }
225
+ if (!script.includes("Build-Envelope")) {
226
+ addError(result, "L1 Windows: missing 'Build-Envelope' function — L0 log/result records would never be folded into a payload");
227
+ }
228
+ if (!script.includes("$env:NEXUS_TIMEOUT_SECONDS") ||
229
+ !script.includes("$job.Shell.Stop()") ||
230
+ !script.includes("$collected = @($job.Output)")) {
231
+ addError(result, "L1 Windows: missing runspace timeout/partial-output handling — records emitted before timeout would be lost");
232
+ }
233
+ // Runspace pool — the parallelism mechanism
234
+ if (!script.includes("RunspacePool")) {
235
+ addWarning(result, "L1 Windows: missing RunspacePool — modules may not run in parallel");
236
+ }
237
+ // Here-string terminators — must conform to PowerShell 5.1 rules
238
+ const terminatorErrors = validateHereStringTerminators(script);
239
+ for (const err of terminatorErrors) {
240
+ addError(result, `L1 Windows: ${err}`);
241
+ }
242
+ // Mixed line endings — PowerShell 5.1 can fail with mixed \r\n and \n
243
+ const hasCRLF = script.includes("\r\n");
244
+ const bareLF = (script.match(/(?<!\r)\n/g) || []).length;
245
+ if (hasCRLF && bareLF > 0) {
246
+ addError(result, `L1 Windows: mixed line endings — ${bareLF} bare LF lines mixed with CRLF`);
247
+ }
248
+ return result;
249
+ }
250
+ export function validateL1(script, osFamily, moduleNames) {
251
+ log.info("Validating L1 script", { osFamily, moduleCount: moduleNames.length });
252
+ const placeholders = checkUnreplacedPlaceholders(script, "L1");
253
+ const structural = osFamily === "linux"
254
+ ? validateL1Linux(script, moduleNames)
255
+ : osFamily === "aix"
256
+ ? validateL1Aix(script, moduleNames)
257
+ : validateL1Windows(script, moduleNames);
258
+ const result = mergeResults(placeholders, structural);
259
+ if (result.valid) {
260
+ log.info("L1 validation passed", { warnings: result.warnings.length });
261
+ }
262
+ else {
263
+ log.error("L1 validation failed", {
264
+ errorCount: result.errors.length,
265
+ errors: result.errors,
266
+ });
267
+ }
268
+ for (const w of result.warnings) {
269
+ log.debug("L1 validation warning", { warning: w });
270
+ }
271
+ return result;
272
+ }
273
+ // ── L2 Validation ──────────────────────────────────────────────────────────
274
+ function validateL2Linux(script) {
275
+ const result = createResult();
276
+ // Shebang
277
+ if (!script.startsWith("#!/bin/bash")) {
278
+ addError(result, "L2 Linux: missing #!/bin/bash shebang");
279
+ }
280
+ // Shell safety flags
281
+ if (!script.includes("set -euo pipefail")) {
282
+ addError(result, "L2 Linux: missing 'set -euo pipefail' safety flags");
283
+ }
284
+ // Named flag argument parser
285
+ if (!script.includes("--service-name")) {
286
+ addError(result, "L2 Linux: missing --service-name flag in argument parser");
287
+ }
288
+ // Composition default variables
289
+ const requiredVars = [
290
+ "BUFFER_TIME",
291
+ "ICEBERG_ENDPOINT",
292
+ "AGENT_ID",
293
+ "AUTH_TOKEN",
294
+ "ORG_ID",
295
+ "MAX_PARALLEL",
296
+ "TIMEOUT",
297
+ ];
298
+ for (const v of requiredVars) {
299
+ if (!script.includes(`${v}=`)) {
300
+ addError(result, `L2 Linux: missing variable ${v}`);
301
+ }
302
+ }
303
+ // L1 body embedding
304
+ if (!script.includes("NEXUS_L1_SCRIPT_BOUNDARY")) {
305
+ addError(result, "L2 Linux: missing NEXUS_L1_SCRIPT_BOUNDARY heredoc — L1 not embedded");
306
+ }
307
+ // Env file — must write all NEXUS_* vars so L1 can read them at runtime
308
+ for (const envVar of NEXUS_ENV_VARS) {
309
+ if (!script.includes(`${envVar}=`)) {
310
+ addError(result, `L2 Linux: env file does not set ${envVar} — L1 will fail to read it`);
311
+ }
312
+ }
313
+ // Systemd units
314
+ if (!script.includes(".service")) {
315
+ addError(result, "L2 Linux: missing systemd service unit creation");
316
+ }
317
+ if (!script.includes(".timer")) {
318
+ addError(result, "L2 Linux: missing systemd timer unit creation");
319
+ }
320
+ if (!script.includes("EnvironmentFile=")) {
321
+ addError(result, "L2 Linux: systemd service missing EnvironmentFile= directive — env vars won't reach L1");
322
+ }
323
+ if (!script.includes("systemctl daemon-reload")) {
324
+ addError(result, "L2 Linux: missing 'systemctl daemon-reload'");
325
+ }
326
+ if (!script.includes("systemctl enable")) {
327
+ addWarning(result, "L2 Linux: missing 'systemctl enable' — timer won't survive reboot");
328
+ }
329
+ if (!script.includes("systemctl start")) {
330
+ addWarning(result, "L2 Linux: missing 'systemctl start' — timer won't begin immediately");
331
+ }
332
+ // ── systemd 228 compatibility (SLES 12 SP5) ────────────────────────────────
333
+ // Each of these directives breaks on systemd < a particular version. Checks
334
+ // are anchored at start-of-line so explanatory comments inside heredocs
335
+ // (which mention the broken directive names by design) don't false-positive.
336
+ // Type=oneshot + Restart= → forbidden until systemd 244
337
+ // StandardOutput=append: → introduced in systemd 240
338
+ // StartLimitIntervalSec= → renamed (was StartLimitInterval=) in 230
339
+ // RandomizedDelaySec= → introduced in systemd 229 (warn-only)
340
+ const hasOneshotDirective = /^Type=oneshot\s*$/m.test(script);
341
+ const hasRealRestart = /^Restart=(?!no\b)/m.test(script);
342
+ if (hasOneshotDirective && hasRealRestart) {
343
+ addError(result, "L2 Linux: Type=oneshot combined with Restart= is rejected by systemd < 244 (SLES 12). Use Type=simple.");
344
+ }
345
+ if (!/^Type=simple\s*$/m.test(script)) {
346
+ addError(result, "L2 Linux: missing 'Type=simple' — required for systemd 228 + Restart= compatibility");
347
+ }
348
+ if (/^StandardOutput=append:/m.test(script) ||
349
+ /^StandardError=append:/m.test(script)) {
350
+ addError(result, "L2 Linux: 'append:' output specifier requires systemd >= 240 (breaks SLES 12). Use in-script 'exec >>' redirect in L1 instead.");
351
+ }
352
+ if (/^StartLimitIntervalSec=/m.test(script)) {
353
+ addError(result, "L2 Linux: 'StartLimitIntervalSec=' requires systemd >= 230. Use 'StartLimitInterval=' (in [Service]) for systemd 228 compatibility.");
354
+ }
355
+ if (!/^StartLimitInterval=/m.test(script)) {
356
+ addError(result, "L2 Linux: missing rate-limit directive 'StartLimitInterval=' — required to throttle restart loops");
357
+ }
358
+ if (/^RandomizedDelaySec=/m.test(script)) {
359
+ addWarning(result, "L2 Linux: 'RandomizedDelaySec=' requires systemd >= 229 and is ignored on 228. Drop it unless cross-host trigger jitter is genuinely needed.");
360
+ }
361
+ if (!script.includes("NEXUS_SERVICE_NAME=")) {
362
+ addError(result, "L2 Linux: env file does not set NEXUS_SERVICE_NAME — L1 cannot redirect its own logs");
363
+ }
364
+ if (!/^TimeoutStopSec=\S+/m.test(script)) {
365
+ addError(result, "L2 Linux: missing TimeoutStopSec grace period — L1 may be killed before partial timeout records are posted");
366
+ }
367
+ return result;
368
+ }
369
+ function validateL2Aix(script) {
370
+ const result = createResult();
371
+ // Shebang
372
+ if (!script.startsWith("#!/bin/ksh")) {
373
+ addError(result, "L2 AIX: missing #!/bin/ksh shebang");
374
+ }
375
+ // Shell safety flags — ksh88 has no `pipefail`.
376
+ if (!/^set -u\b/m.test(script)) {
377
+ addError(result, "L2 AIX: missing 'set -u' safety flag");
378
+ }
379
+ // Named flag argument parser
380
+ if (!script.includes("--service-name")) {
381
+ addError(result, "L2 AIX: missing --service-name flag in argument parser");
382
+ }
383
+ // Composition default variables — the SRC wrapper uses integer seconds.
384
+ const requiredVars = [
385
+ "BUFFER_TIME_SECONDS",
386
+ "TIMEOUT_SECONDS",
387
+ "ICEBERG_ENDPOINT",
388
+ "AGENT_ID",
389
+ "AUTH_TOKEN",
390
+ "ORG_ID",
391
+ "MAX_PARALLEL",
392
+ ];
393
+ for (const v of requiredVars) {
394
+ if (!script.includes(`${v}=`)) {
395
+ addError(result, `L2 AIX: missing variable ${v}`);
396
+ }
397
+ }
398
+ // L1 body embedding — same heredoc marker as Linux
399
+ if (!script.includes("NEXUS_L1_SCRIPT_BOUNDARY")) {
400
+ addError(result, "L2 AIX: missing NEXUS_L1_SCRIPT_BOUNDARY heredoc — L1 not embedded");
401
+ }
402
+ // Env file — must write all NEXUS_* vars so L1 can read them at runtime
403
+ for (const envVar of NEXUS_ENV_VARS) {
404
+ if (!script.includes(`${envVar}=`)) {
405
+ addError(result, `L2 AIX: env file does not set ${envVar} — L1 will fail to read it`);
406
+ }
407
+ }
408
+ if (!script.includes("NEXUS_SERVICE_NAME=")) {
409
+ addError(result, "L2 AIX: env file does not set NEXUS_SERVICE_NAME — L1 cannot redirect its own logs");
410
+ }
411
+ // ── SRC subsystem registration ─────────────────────────────────────────
412
+ // SRC wants a daemon that never exits; L2 registers the ksh wrapper as a
413
+ // subsystem and boots it from inittab.
414
+ if (!script.includes("mkssys")) {
415
+ addError(result, "L2 AIX: missing 'mkssys' — SRC subsystem is never registered");
416
+ }
417
+ if (!script.includes("mkitab")) {
418
+ addError(result, "L2 AIX: missing 'mkitab' — subsystem won't start at boot");
419
+ }
420
+ if (!script.includes("startsrc")) {
421
+ addWarning(result, "L2 AIX: missing 'startsrc' — subsystem won't begin immediately");
422
+ }
423
+ // ── Teardown of previous / same-name deployments ────────────────────────
424
+ // Without these the old wrapper daemon keeps running and spawning L1 in
425
+ // parallel with the new deployment (the AIX analog of the Windows
426
+ // Stop-NexusModScript teardown).
427
+ if (!script.includes("stopsrc")) {
428
+ addError(result, "L2 AIX: missing 'stopsrc' — previous subsystem won't be stopped on redeploy");
429
+ }
430
+ if (!script.includes("rmssys")) {
431
+ addError(result, "L2 AIX: missing 'rmssys' — previous SRC subsystem definition will leak in the ODM");
432
+ }
433
+ if (!script.includes("rmitab")) {
434
+ addError(result, "L2 AIX: missing 'rmitab' — previous inittab boot entry will leak");
435
+ }
436
+ // ── Persistent wrapper daemon (the actual SRC-managed process) ──────────
437
+ // It must never exit (while loop), trap SIGTERM for a clean stopsrc, and
438
+ // enforce the per-run timeout by killing the L1 child (watchdog).
439
+ if (!/\bwhile\s+(true|:)\b/.test(script)) {
440
+ addError(result, "L2 AIX: wrapper daemon missing 'while true' loop — script would exit and SRC would mark it inoperative");
441
+ }
442
+ if (!script.includes("trap")) {
443
+ addError(result, "L2 AIX: wrapper daemon missing 'trap' — stopsrc would wait the full -w window before SIGKILL");
444
+ }
445
+ if (!/\bkill\b/.test(script)) {
446
+ addError(result, "L2 AIX: wrapper daemon missing 'kill' — the per-run timeout watchdog cannot force-stop a hung L1");
447
+ }
448
+ // ksh88 incompatibilities
449
+ for (const err of detectKsh88Incompatibilities(script, "L2 AIX")) {
450
+ addError(result, err);
451
+ }
452
+ return result;
453
+ }
454
+ /**
455
+ * Detect bash/ksh93-only constructs that AIX's default /bin/ksh (ksh88) rejects.
456
+ * This catches the class of bugs that `ksh -n` on a dev box misses, because dev
457
+ * `ksh` is almost always ksh93 (and macOS ships ksh93), which happily parses
458
+ * here-strings, process substitution, indirect/substring expansion, etc.
459
+ *
460
+ * Comments are stripped first so explanatory prose mentioning a construct
461
+ * doesn't false-positive. The checks scan code only.
462
+ */
463
+ function detectKsh88Incompatibilities(script, label) {
464
+ const errors = [];
465
+ // Strip full-line and trailing comments (heuristic — good enough for token
466
+ // presence detection; avoids the obvious comment false-positives).
467
+ const code = script
468
+ .split("\n")
469
+ .map((line) => line.replace(/(^|\s)#.*$/, "$1"))
470
+ .join("\n");
471
+ const checks = [
472
+ { re: /<<</, msg: "here-strings ('<<<') — use 'print -r -- \"$x\" | cmd'" },
473
+ { re: /[<>]\(/, msg: "process substitution ('<(...)'/'>(...)') — stage to a temp file and redirect" },
474
+ { re: /\$\{!/, msg: "indirect expansion ('${!var}') — use eval to read the named variable" },
475
+ // Offset form is `${var:N}`, `${var:N:M}` or `${var: -N}` (space before the
476
+ // minus). NOT `${var:-default}` (no space) — that's a valid ksh88 default.
477
+ { re: /\$\{[A-Za-z_][A-Za-z0-9_]*:( +-?[0-9]+|[0-9]+)(:-?[0-9]+)?\}/, msg: "substring offset ('${var:n:m}'/'${var: -n}') — use prefix/suffix removal or sed" },
478
+ { re: /\bwait -n\b/, msg: "'wait -n' — use a batch concurrency gate" },
479
+ { re: /(^|;)\s*local\s+\w/m, msg: "'local' — use 'typeset' for function-local vars" },
480
+ { re: /&>/, msg: "'&>' redirect — use '>file 2>&1'" },
481
+ { re: /\bmapfile\b|\breadarray\b/, msg: "'mapfile'/'readarray' — read with a while loop" },
482
+ { re: /\bprintf\s+-v\b/, msg: "'printf -v' — capture with command substitution" },
483
+ // These are the nastiest failures of the lot: ksh88 simply doesn't define
484
+ // them, so under the `set -u` L1 inherits, the *first* reference aborts the
485
+ // collector mid-run. It emits no result record and the module silently
486
+ // shows up as "No output from nexus_collect_<name>".
487
+ { re: /\$\{?E?UID\b/, msg: "'$EUID'/'$UID' — undefined in ksh88; under 'set -u' the first reference aborts the collector. Use '$(id -u)'" },
488
+ { re: /\$\{?BASH_[A-Z_]+/, msg: "a '$BASH_*' variable — undefined in ksh88; under 'set -u' the first reference aborts the collector" },
489
+ ];
490
+ for (const { re, msg } of checks) {
491
+ if (re.test(code)) {
492
+ errors.push(`${label}: uses ${msg} — unsupported in ksh88 (AIX /bin/ksh).`);
493
+ }
494
+ }
495
+ return errors;
496
+ }
497
+ function validateL2Windows(script) {
498
+ const result = createResult();
499
+ // Param block
500
+ if (!script.includes("param(")) {
501
+ addError(result, "L2 Windows: missing param() block");
502
+ }
503
+ // ServiceName is the only required runtime param() parameter
504
+ // All other config values are baked as variable assignments at composition time
505
+ const requiredVars = [
506
+ "ServiceName",
507
+ "BufferTimeSeconds",
508
+ "TimeoutSeconds",
509
+ "IcebergEndpoint",
510
+ "AgentId",
511
+ "AuthToken",
512
+ "OrgId",
513
+ ];
514
+ for (const v of requiredVars) {
515
+ if (!script.includes(`$${v}`)) {
516
+ addError(result, `L2 Windows: missing variable $${v}`);
517
+ }
518
+ }
519
+ // L1 body embedding — PowerShell here-string
520
+ if (!script.includes("@'") || !script.includes("'@")) {
521
+ addError(result, "L2 Windows: missing here-string markers — L1 not embedded");
522
+ }
523
+ // Here-string terminators — must conform to PowerShell 5.1 rules
524
+ const terminatorErrors = validateHereStringTerminators(script);
525
+ for (const err of terminatorErrors) {
526
+ addError(result, `L2 Windows: ${err}`);
527
+ }
528
+ // Mixed line endings — PowerShell 5.1 can fail with mixed \r\n and \n
529
+ const hasCRLF = script.includes("\r\n");
530
+ const bareLF = (script.match(/(?<!\r)\n/g) || []).length;
531
+ if (hasCRLF && bareLF > 0) {
532
+ addError(result, `L2 Windows: mixed line endings — ${bareLF} bare LF lines mixed with CRLF`);
533
+ }
534
+ // Env file — must write all NEXUS_* vars
535
+ for (const envVar of NEXUS_ENV_VARS) {
536
+ if (!script.includes(`${envVar}=`)) {
537
+ addError(result, `L2 Windows: env.conf does not set ${envVar} — L1 will fail to read it`);
538
+ }
539
+ }
540
+ if (!script.includes("NEXUS_TIMEOUT_SECONDS=")) {
541
+ addError(result, "L2 Windows: env.conf does not set NEXUS_TIMEOUT_SECONDS — L1 cannot stop runspaces gracefully before the wrapper hard timeout");
542
+ }
543
+ // Wrapper script — loads env and runs L1
544
+ if (!script.includes("wrapper.ps1")) {
545
+ addError(result, "L2 Windows: missing wrapper.ps1 creation — env vars won't be loaded before L1 runs");
546
+ }
547
+ // SetEnvironmentVariable in wrapper — the mechanism that feeds env vars to L1
548
+ if (!script.includes("SetEnvironmentVariable")) {
549
+ addError(result, "L2 Windows: wrapper missing SetEnvironmentVariable — env vars won't propagate to L1 process");
550
+ }
551
+ // Scheduled task
552
+ if (!script.includes("Register-ScheduledTask")) {
553
+ addError(result, "L2 Windows: missing Register-ScheduledTask");
554
+ }
555
+ // Teardown of previous / same-name deployments — must kill the running
556
+ // wrapper.ps1 + l1-script.ps1 processes, not just unregister the task.
557
+ // Without these markers the L2 only does a soft unregister and orphans the
558
+ // previous wrapper's `while ($true)` loop, which keeps spawning L1 in
559
+ // parallel with the new deployment.
560
+ if (!script.includes("Stop-NexusModScript")) {
561
+ addError(result, "L2 Windows: missing Stop-NexusModScript helper — teardown of previous deployment is incomplete");
562
+ }
563
+ // Helper must accept both bare service names and the prefixed task name
564
+ // returned in the L2 output JSON's 'service_id' field. Without this
565
+ // normalisation an orchestrator round-tripping service_id back into
566
+ // -PreviousServiceId silently doubles the prefix and matches nothing.
567
+ if (!script.includes('StartsWith("NexusModScript-")')) {
568
+ addError(result, "L2 Windows: Stop-NexusModScript missing prefix normalisation — callers passing the JSON 'service_id' value will silently miss every cleanup target");
569
+ }
570
+ if (!script.includes("Stop-ScheduledTask")) {
571
+ addError(result, "L2 Windows: missing Stop-ScheduledTask — previous task instances will keep running after Unregister-ScheduledTask");
572
+ }
573
+ if (!script.includes("Win32_Process")) {
574
+ addError(result, "L2 Windows: missing Win32_Process lookup — previous wrapper.ps1 / l1-script.ps1 processes will not be killed on redeploy");
575
+ }
576
+ // Error handling
577
+ if (!script.includes('$ErrorActionPreference = "Stop"')) {
578
+ addWarning(result, "L2 Windows: ErrorActionPreference not set to Stop — errors may be silently ignored");
579
+ }
580
+ return result;
581
+ }
582
+ export function validateL2(script, osFamily) {
583
+ log.info("Validating L2 script", { osFamily });
584
+ const placeholders = checkUnreplacedPlaceholders(script, "L2");
585
+ const structural = osFamily === "linux"
586
+ ? validateL2Linux(script)
587
+ : osFamily === "aix"
588
+ ? validateL2Aix(script)
589
+ : validateL2Windows(script);
590
+ const result = mergeResults(placeholders, structural);
591
+ if (result.valid) {
592
+ log.info("L2 validation passed", { warnings: result.warnings.length });
593
+ }
594
+ else {
595
+ log.error("L2 validation failed", {
596
+ errorCount: result.errors.length,
597
+ errors: result.errors,
598
+ });
599
+ }
600
+ for (const w of result.warnings) {
601
+ log.debug("L2 validation warning", { warning: w });
602
+ }
603
+ return result;
604
+ }
605
+ // ── Parameter flow: L2 → env file → L1 ────────────────────────────────────
606
+ export function validateParameterFlow(l1Script, l2Script, osFamily, orgId) {
607
+ log.info("Validating parameter flow L2 → L1", { osFamily });
608
+ const result = createResult();
609
+ // 1. Every env var that L2 writes must be consumed by L1
610
+ for (const envVar of NEXUS_ENV_VARS) {
611
+ const l2Writes = l2Script.includes(`${envVar}=`);
612
+ const l1Reads = osFamily === "windows"
613
+ ? l1Script.includes(`$env:${envVar}`)
614
+ : l1Script.includes(envVar);
615
+ if (l2Writes && !l1Reads) {
616
+ addWarning(result, `Parameter flow: L2 sets ${envVar} but L1 never reads it`);
617
+ }
618
+ if (!l2Writes && l1Reads) {
619
+ addError(result, `Parameter flow: L1 reads ${envVar} but L2 never writes it to env file — will be empty at runtime`);
620
+ }
621
+ if (!l2Writes && !l1Reads) {
622
+ addError(result, `Parameter flow: ${envVar} is neither written by L2 nor read by L1 — broken contract`);
623
+ }
624
+ }
625
+ // 2. ORG_ID is baked directly into L1 (not via env), verify it was substituted
626
+ if (osFamily === "linux" || osFamily === "aix") {
627
+ // In L1 Linux/AIX, ORG_ID appears as a literal string in the
628
+ // _push_to_iceberg envelope. Check it's not still a placeholder and not empty.
629
+ if (l1Script.includes("{{ORG_ID}}")) {
630
+ addError(result, "Parameter flow: ORG_ID was not substituted in L1 — still a placeholder");
631
+ }
632
+ // Verify actual orgId value appears in the script
633
+ if (!l1Script.includes(`'org_id': '${orgId}'`) &&
634
+ !l1Script.includes(`"org_id":"${orgId}"`) &&
635
+ !l1Script.includes(`--arg cid "${orgId}"`)) {
636
+ addWarning(result, `Parameter flow: ORG_ID value '${orgId}' not found baked into L1 ${osFamily} envelope`);
637
+ }
638
+ }
639
+ else {
640
+ if (l1Script.includes("{{ORG_ID}}")) {
641
+ addError(result, "Parameter flow: ORG_ID was not substituted in L1 — still a placeholder");
642
+ }
643
+ if (!l1Script.includes(`$OrgId = "${orgId}"`)) {
644
+ addWarning(result, `Parameter flow: ORG_ID value '${orgId}' not found baked into L1 Windows $OrgId variable`);
645
+ }
646
+ }
647
+ // 3. L2 must embed the L1 body — without this the whole chain is broken.
648
+ // Linux & AIX use the NEXUS_L1_SCRIPT_BOUNDARY heredoc; Windows a here-string.
649
+ if (osFamily === "windows") {
650
+ if (!l2Script.includes("$l1Content = @'")) {
651
+ addError(result, "Parameter flow: L2 does not embed L1 script — deployment will have no collection logic");
652
+ }
653
+ }
654
+ else {
655
+ if (!l2Script.includes("NEXUS_L1_SCRIPT_BOUNDARY")) {
656
+ addError(result, "Parameter flow: L2 does not embed L1 script — deployment will have no collection logic");
657
+ }
658
+ }
659
+ // 4. L2 env file must be loaded before L1 runs.
660
+ // Linux: systemd EnvironmentFile=. Windows: wrapper SetEnvironmentVariable.
661
+ // AIX: the SRC wrapper daemon sources the env file (`. <file>`) before exec'ing L1.
662
+ if (osFamily === "linux") {
663
+ if (!l2Script.includes("EnvironmentFile=")) {
664
+ addError(result, "Parameter flow: systemd service has no EnvironmentFile — L1 will run without env vars");
665
+ }
666
+ }
667
+ else if (osFamily === "aix") {
668
+ // Match a POSIX `.`/`source` of the env file in the wrapper daemon.
669
+ // The wrapper is written via an unquoted heredoc, so its runtime `$`
670
+ // appears escaped (`\$ENV_FILE`) in the composed L2 — allow the backslash.
671
+ if (!/(^|\s)(\.|source)\s+["']?\\?\$?\{?(INSTALL_DIR|ENV_FILE)/m.test(l2Script)) {
672
+ addError(result, "Parameter flow: AIX wrapper daemon doesn't source the env file — L1 will run without env vars");
673
+ }
674
+ }
675
+ else {
676
+ if (!l2Script.includes("SetEnvironmentVariable")) {
677
+ addError(result, "Parameter flow: Windows wrapper doesn't call SetEnvironmentVariable — L1 will run without env vars");
678
+ }
679
+ }
680
+ if (result.valid) {
681
+ log.info("Parameter flow validation passed", {
682
+ warnings: result.warnings.length,
683
+ });
684
+ }
685
+ else {
686
+ log.error("Parameter flow validation failed", {
687
+ errorCount: result.errors.length,
688
+ errors: result.errors,
689
+ });
690
+ }
691
+ for (const w of result.warnings) {
692
+ log.debug("Parameter flow warning", { warning: w });
693
+ }
694
+ return result;
695
+ }
696
+ // ── Shell syntax checks ────────────────────────────────────────────────────
697
+ function shellAvailable(cmd) {
698
+ try {
699
+ execSync(`which ${cmd}`, { stdio: "ignore" });
700
+ return true;
701
+ }
702
+ catch {
703
+ return false;
704
+ }
705
+ }
706
+ function writeTempFile(content, ext) {
707
+ const name = `nexus-validate-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
708
+ const path = join(tmpdir(), name);
709
+ writeFileSync(path, content, "utf-8");
710
+ return path;
711
+ }
712
+ function removeTempFile(path) {
713
+ try {
714
+ unlinkSync(path);
715
+ }
716
+ catch {
717
+ // best-effort cleanup
718
+ }
719
+ }
720
+ export async function syntaxCheckBash(script, label) {
721
+ return syntaxCheckShellLike(script, label, "bash");
722
+ }
723
+ export async function syntaxCheckKsh(script, label) {
724
+ // ⚠️ Dev/CI `ksh` is typically ksh93, which is more lenient than the AIX
725
+ // default ksh88. `ksh -n` catches gross syntax errors but won't flag every
726
+ // ksh88-only regression — real ksh88 safety comes from the hand-written
727
+ // templates and the structural ksh88 guards in validateL1Aix/validateL2Aix.
728
+ return syntaxCheckShellLike(script, label, "ksh");
729
+ }
730
+ async function syntaxCheckShellLike(script, label, interpreter) {
731
+ const result = createResult();
732
+ const tmpPath = writeTempFile(script, "sh");
733
+ try {
734
+ // Phase 1: `<interpreter> -n` for basic syntax validation
735
+ if (shellAvailable(interpreter)) {
736
+ log.debug(`Running ${interpreter} -n syntax check`, { label, tmpPath });
737
+ try {
738
+ execSync(`${interpreter} -n "${tmpPath}"`, {
739
+ stdio: ["ignore", "ignore", "pipe"],
740
+ timeout: 15_000,
741
+ });
742
+ log.info(`${interpreter} -n syntax check passed`, { label });
743
+ }
744
+ catch (err) {
745
+ const stderr = err instanceof Error && "stderr" in err
746
+ ? String(err.stderr)
747
+ : String(err);
748
+ const cleanErr = stderr.replace(new RegExp(tmpPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), label);
749
+ addError(result, `${label}: ${interpreter} syntax error:\n${cleanErr.trim()}`);
750
+ log.error(`${interpreter} -n syntax check failed`, { label, error: cleanErr.trim() });
751
+ }
752
+ }
753
+ else {
754
+ addWarning(result, `${label}: ${interpreter} not found — skipping ${interpreter} -n check`);
755
+ }
756
+ // Phase 2: shellcheck for deeper lint (SC errors = errors, SC warnings = warnings)
757
+ // Uses bundled platform-specific binary, falls back to system binary
758
+ log.debug("Running shellcheck", { label, tmpPath });
759
+ let shellcheckOutput = null;
760
+ const shellcheckBin = getShellcheckPath();
761
+ if (shellcheckBin !== null) {
762
+ try {
763
+ const buf = execSync(`"${shellcheckBin}" -f json -S error "${tmpPath}"`, {
764
+ stdio: ["ignore", "pipe", "ignore"],
765
+ timeout: 30_000,
766
+ });
767
+ shellcheckOutput = buf.toString("utf8");
768
+ }
769
+ catch (scErr) {
770
+ // shellcheck exits non-zero when it finds issues — stdout still has JSON
771
+ if (scErr instanceof Error && "stdout" in scErr) {
772
+ shellcheckOutput = String(scErr.stdout);
773
+ }
774
+ }
775
+ }
776
+ if (shellcheckOutput !== null) {
777
+ let findings = [];
778
+ try {
779
+ findings = JSON.parse(shellcheckOutput);
780
+ }
781
+ catch {
782
+ addWarning(result, `${label}: shellcheck returned non-JSON output`);
783
+ }
784
+ const errors = findings.filter((f) => f.level === "error");
785
+ const warnings = findings.filter((f) => f.level === "warning");
786
+ for (const f of errors) {
787
+ addError(result, `${label}: shellcheck SC${f.code} (line ${f.line}:${f.column}): ${f.message}`);
788
+ }
789
+ for (const f of warnings) {
790
+ addWarning(result, `${label}: shellcheck SC${f.code} (line ${f.line}:${f.column}): ${f.message}`);
791
+ }
792
+ if (errors.length === 0) {
793
+ log.info("shellcheck passed", { label, warnings: warnings.length });
794
+ }
795
+ else {
796
+ log.error("shellcheck found errors", { label, errorCount: errors.length, warningCount: warnings.length });
797
+ }
798
+ }
799
+ else {
800
+ addWarning(result, `${label}: shellcheck not available — skipping deep lint`);
801
+ log.info("shellcheck not available, skipping", { label });
802
+ }
803
+ }
804
+ finally {
805
+ removeTempFile(tmpPath);
806
+ }
807
+ return result;
808
+ }
809
+ export async function syntaxCheckPowerShell(script, label) {
810
+ const result = createResult();
811
+ if (!shellAvailable("pwsh")) {
812
+ addWarning(result, `${label}: pwsh not found — skipping PowerShell syntax check`);
813
+ log.info("PowerShell syntax check skipped — pwsh not available", { label });
814
+ return result;
815
+ }
816
+ const tmpPath = writeTempFile(script, "ps1");
817
+ // Write a separate validator script so shell doesn't eat PowerShell's $ variables
818
+ const validatorScript = [
819
+ `$null = [System.Management.Automation.Language.Parser]::ParseFile('${tmpPath}', [ref]$null, [ref]$errors)`,
820
+ `if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.ToString() }; exit 1 }`,
821
+ ].join("\n");
822
+ const validatorPath = writeTempFile(validatorScript, "ps1");
823
+ try {
824
+ log.debug("Running pwsh syntax check", { label, tmpPath });
825
+ execSync(`pwsh -NoProfile -NonInteractive -File "${validatorPath}"`, {
826
+ stdio: ["ignore", "ignore", "pipe"],
827
+ timeout: 30_000,
828
+ });
829
+ log.info("PowerShell syntax check passed", { label });
830
+ }
831
+ catch (err) {
832
+ const stderr = err instanceof Error && "stderr" in err
833
+ ? String(err.stderr)
834
+ : String(err);
835
+ const cleanErr = stderr
836
+ .replace(new RegExp(tmpPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), label)
837
+ .replace(new RegExp(validatorPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "(validator)");
838
+ addError(result, `${label}: PowerShell syntax error:\n${cleanErr.trim()}`);
839
+ log.error("PowerShell syntax check failed", { label, error: cleanErr.trim() });
840
+ }
841
+ finally {
842
+ removeTempFile(tmpPath);
843
+ removeTempFile(validatorPath);
844
+ }
845
+ return result;
846
+ }
847
+ async function syntaxCheck(script, osFamily, label) {
848
+ if (osFamily === "linux") {
849
+ return syntaxCheckBash(script, label);
850
+ }
851
+ else if (osFamily === "aix") {
852
+ return syntaxCheckKsh(script, label);
853
+ }
854
+ else {
855
+ return syntaxCheckPowerShell(script, label);
856
+ }
857
+ }
858
+ // ── L2 structural dry-run ──────────────────────────────────────────────────
859
+ function extractL1FromL2Linux(l2Script) {
860
+ // L1 is embedded between heredoc markers:
861
+ // cat > "$SCRIPT_PATH" << 'NEXUS_L1_SCRIPT_BOUNDARY'
862
+ // ...l1 content...
863
+ // NEXUS_L1_SCRIPT_BOUNDARY
864
+ const startMarker = "NEXUS_L1_SCRIPT_BOUNDARY";
865
+ const startIdx = l2Script.indexOf(startMarker);
866
+ if (startIdx === -1)
867
+ return null;
868
+ // Find the first newline after the opening marker line
869
+ const contentStart = l2Script.indexOf("\n", startIdx) + 1;
870
+ if (contentStart === 0)
871
+ return null;
872
+ // Find the closing marker (standalone on its own line)
873
+ const closingPattern = new RegExp(`^${startMarker}$`, "m");
874
+ const remaining = l2Script.slice(contentStart);
875
+ const closingMatch = closingPattern.exec(remaining);
876
+ if (!closingMatch)
877
+ return null;
878
+ return remaining.slice(0, closingMatch.index);
879
+ }
880
+ function extractL1FromL2Windows(l2Script) {
881
+ // L1 is embedded in a PowerShell here-string:
882
+ // $l1Content = @'
883
+ // ...l1 content...
884
+ // '@
885
+ const startMarker = "$l1Content = @'";
886
+ const startIdx = l2Script.indexOf(startMarker);
887
+ if (startIdx === -1)
888
+ return null;
889
+ const contentStart = l2Script.indexOf("\n", startIdx) + 1;
890
+ if (contentStart === 0)
891
+ return null;
892
+ // Closing here-string: '@ at the start of a line
893
+ const remaining = l2Script.slice(contentStart);
894
+ const closingMatch = /^'@/m.exec(remaining);
895
+ if (!closingMatch)
896
+ return null;
897
+ return remaining.slice(0, closingMatch.index);
898
+ }
899
+ export async function validateL2StructuralDryRun(l1Script, l2Script, osFamily, moduleNames) {
900
+ log.info("Running L2 structural dry-run", { osFamily });
901
+ const result = createResult();
902
+ // 1. Extract the L1 body embedded inside L2.
903
+ // AIX uses the same NEXUS_L1_SCRIPT_BOUNDARY heredoc as Linux.
904
+ const extractedL1 = osFamily === "windows"
905
+ ? extractL1FromL2Windows(l2Script)
906
+ : extractL1FromL2Linux(l2Script);
907
+ if (extractedL1 === null) {
908
+ addError(result, "L2 dry-run: could not extract embedded L1 body from L2 script — " +
909
+ (osFamily === "windows"
910
+ ? "here-string @'...'@ block not found"
911
+ : "NEXUS_L1_SCRIPT_BOUNDARY heredoc not found"));
912
+ log.error("L2 dry-run: L1 extraction failed", { osFamily });
913
+ return result;
914
+ }
915
+ log.debug("Extracted L1 from L2", {
916
+ extractedLength: extractedL1.length,
917
+ standaloneLength: l1Script.length,
918
+ });
919
+ // 2. Verify extracted L1 matches the standalone L1 we composed
920
+ const extractedTrimmed = extractedL1.trim();
921
+ const standaloneTrimmed = l1Script.trim();
922
+ if (extractedTrimmed !== standaloneTrimmed) {
923
+ // Find where they diverge for a useful error
924
+ let divergeIdx = 0;
925
+ const minLen = Math.min(extractedTrimmed.length, standaloneTrimmed.length);
926
+ while (divergeIdx < minLen && extractedTrimmed[divergeIdx] === standaloneTrimmed[divergeIdx]) {
927
+ divergeIdx++;
928
+ }
929
+ const contextStart = Math.max(0, divergeIdx - 40);
930
+ const extractedSnippet = extractedTrimmed.slice(contextStart, divergeIdx + 40);
931
+ const standaloneSnippet = standaloneTrimmed.slice(contextStart, divergeIdx + 40);
932
+ addError(result, `L2 dry-run: embedded L1 does not match standalone L1 — ` +
933
+ `diverges at byte ${divergeIdx}. ` +
934
+ `Embedded: "...${extractedSnippet}..." vs Standalone: "...${standaloneSnippet}..."`);
935
+ log.error("L2 dry-run: L1 content mismatch", {
936
+ divergeIdx,
937
+ extractedLength: extractedTrimmed.length,
938
+ standaloneLength: standaloneTrimmed.length,
939
+ });
940
+ }
941
+ else {
942
+ log.info("L2 dry-run: embedded L1 matches standalone L1");
943
+ }
944
+ // 3. Syntax-check the extracted L1 (proves the embedding didn't corrupt it)
945
+ const extractedSyntax = await syntaxCheck(extractedTrimmed, osFamily, "L2-embedded-L1");
946
+ for (const e of extractedSyntax.errors) {
947
+ addError(result, `L2 dry-run: ${e}`);
948
+ }
949
+ for (const w of extractedSyntax.warnings) {
950
+ addWarning(result, `L2 dry-run: ${w}`);
951
+ }
952
+ // 4. Validate the extracted L1 has the expected structure
953
+ // (catches cases where the heredoc boundary sliced wrong)
954
+ if (osFamily === "linux" || osFamily === "aix") {
955
+ const expectedShebang = osFamily === "aix" ? "#!/bin/ksh" : "#!/bin/bash";
956
+ if (!extractedTrimmed.startsWith(expectedShebang)) {
957
+ addError(result, `L2 dry-run: extracted L1 does not start with ${expectedShebang} shebang — heredoc boundary likely wrong`);
958
+ }
959
+ if (!extractedTrimmed.includes("run_all_modules")) {
960
+ addError(result, "L2 dry-run: extracted L1 missing run_all_modules entry point");
961
+ }
962
+ }
963
+ else {
964
+ if (!extractedTrimmed.includes("$ErrorActionPreference")) {
965
+ addError(result, "L2 dry-run: extracted L1 missing $ErrorActionPreference");
966
+ }
967
+ if (!extractedTrimmed.includes("Invoke-AllModules")) {
968
+ addError(result, "L2 dry-run: extracted L1 missing Invoke-AllModules entry point");
969
+ }
970
+ }
971
+ // 5. Verify each module's collector function survived embedding.
972
+ // The Windows name is derived the same way the L1 template derives it:
973
+ // module 'tomcat' -> 'Invoke-NexusCollect_Tomcat'. Checking the bare
974
+ // 'Invoke-NexusCollect_' prefix would let a single collector satisfy every
975
+ // module in the list.
976
+ for (const mod of moduleNames) {
977
+ const fnName = osFamily === "windows"
978
+ ? `Invoke-NexusCollect_${mod.charAt(0).toUpperCase()}${mod.slice(1)}`
979
+ : `nexus_collect_${mod}`;
980
+ if (!extractedTrimmed.includes(fnName)) {
981
+ addError(result, `L2 dry-run: extracted L1 missing function '${fnName}' for module '${mod}'`);
982
+ }
983
+ }
984
+ // 6. Verify L2 env file vars will match what the extracted L1 expects
985
+ for (const envVar of NEXUS_ENV_VARS) {
986
+ const l2Writes = l2Script.includes(`${envVar}=`);
987
+ const l1Reads = osFamily === "windows"
988
+ ? extractedTrimmed.includes(`$env:${envVar}`)
989
+ : extractedTrimmed.includes(envVar);
990
+ if (!l2Writes && l1Reads) {
991
+ addError(result, `L2 dry-run: extracted L1 reads ${envVar} but L2 env file never writes it`);
992
+ }
993
+ }
994
+ if (result.valid) {
995
+ log.info("L2 structural dry-run passed", { warnings: result.warnings.length });
996
+ }
997
+ else {
998
+ log.error("L2 structural dry-run failed", {
999
+ errorCount: result.errors.length,
1000
+ errors: result.errors,
1001
+ });
1002
+ }
1003
+ return result;
1004
+ }
1005
+ export async function validateComposedScripts(params) {
1006
+ log.info("Running composed script validation", {
1007
+ osFamily: params.osFamily,
1008
+ modules: params.moduleNames,
1009
+ });
1010
+ // Phase 1: structural validation (synchronous)
1011
+ const l1Result = validateL1(params.l1Script, params.osFamily, params.moduleNames);
1012
+ const l2Result = validateL2(params.l2Script, params.osFamily);
1013
+ const flowResult = validateParameterFlow(params.l1Script, params.l2Script, params.osFamily, params.orgId);
1014
+ // Phase 2: shell syntax checks — bash -n + shellcheck / pwsh parse
1015
+ const [l1Syntax, l2Syntax] = await Promise.all([
1016
+ syntaxCheck(params.l1Script, params.osFamily, "L1"),
1017
+ syntaxCheck(params.l2Script, params.osFamily, "L2"),
1018
+ ]);
1019
+ // Phase 3: L2 structural dry-run — extract L1 from L2, verify integrity
1020
+ const dryRunResult = await validateL2StructuralDryRun(params.l1Script, params.l2Script, params.osFamily, params.moduleNames);
1021
+ const combined = mergeResults(l1Result, l2Result, flowResult, l1Syntax, l2Syntax, dryRunResult);
1022
+ log.info("Composed script validation complete", {
1023
+ valid: combined.valid,
1024
+ errors: combined.errors.length,
1025
+ warnings: combined.warnings.length,
1026
+ });
1027
+ return combined;
1028
+ }
1029
+ //# sourceMappingURL=script-validator.js.map