pi-apexlang 0.1.0 → 0.2.0

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.
@@ -1,7 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
- import { lstat, readFile, readdir, realpath } from "node:fs/promises";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
3
4
  import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { stripVTControlCharacters } from "node:util";
5
7
 
6
8
  const moduleDirectory = dirname(fileURLToPath(import.meta.url));
7
9
 
@@ -19,11 +21,46 @@ export const APEXLANG_ACTIONS = Object.freeze([
19
21
 
20
22
  const apexctlPath = resolve(APEXLANG_SKILL_ROOT, "tools/apexctl.mjs");
21
23
  const queryValidPropsPath = resolve(APEXLANG_SKILL_ROOT, "tools/query-valid-props.mjs");
24
+ const runtimeRoundtripPath = resolve(moduleDirectory, "apexlang-runtime-roundtrip.mjs");
25
+ const sqlclPtyProxyPath = resolve(moduleDirectory, "apexlang-sqlcl-pty.py");
22
26
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
23
27
  const CONNECTION_NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
24
28
  const WORKSPACE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_$#.-]{0,127}$/;
25
29
  const COMPONENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,255}$/;
26
30
  const DEFAULT_MAX_BUFFER = 8 * 1024 * 1024;
31
+ const SQLCL_WARNING_HEADER_PATTERN = /^APEXlang Compile Warnings:\s*$/gim;
32
+ const SQLCL_IMPORT_TARGET_PATTERN = /^Importing application ID:[^\S\r\n]*(\d+)[^\S\r\n]+into workspace:[^\S\r\n]*([^\r\n]*\S)[^\S\r\n]*$/gm;
33
+ const SQLCL_IMPORT_SUCCESS_PATTERN = /^Import successful\.[^\S\r\n]*$/gm;
34
+ const SQLCL_HARD_FAILURE_PATTERNS = Object.freeze([
35
+ /APEXlang (?:Compile|Import) Errors:/i,
36
+ /(?:^|\s)(?:ORA|SP2|PLS)-\d+/im,
37
+ /(?:^|\s)(?:TNS|DPI)-\d+/im,
38
+ /\b(?:Validation|Import) failed\b/i,
39
+ /\bFATAL(?:!|:|\s|$)/i,
40
+ /\bERROR!(?:\s|$)/i,
41
+ /\[ERROR\]/i,
42
+ /(?:^|[\r\n])\s*(?:SQL>\s*)?ERROR(?:\s*:|\s+)/i,
43
+ /Exception in thread/i,
44
+ /\b(?:SQLRecoverableException|SQLException)\b/i,
45
+ /\bI\/?O Error\b/i,
46
+ /\bConnection failed\b/i,
47
+ /\bconnection (?:refused|reset|closed|timed out)\b/i,
48
+ /\b(?:could not|unable to|failed to)\s+(?:connect|open|read|write|find|import|validate)\b/i,
49
+ /\b(?:permission|access) denied\b/i,
50
+ /\bnot connected\b/i,
51
+ /invalid username\/password/i,
52
+ /(?:enter|provide).*password/i,
53
+ /\b(?:EPERM|ENOENT)\b/,
54
+ /(?:unknown|unrecognized) command/i,
55
+ /No such file or directory/i,
56
+ /Could not find file or directory/i,
57
+ /APEXlang process (?:timed out|output limit exceeded)/i
58
+ ]);
59
+
60
+ const COMPATIBLE_VALIDATION_BLOCKING_REASONS = Object.freeze(new Set([
61
+ "Live APEX validation did not pass or did not produce pass evidence.",
62
+ "problems.json contains unresolved validation problems."
63
+ ]));
27
64
 
28
65
  function assertPlainText(value, key) {
29
66
  if (CONTROL_CHARACTER_PATTERN.test(value)) {
@@ -105,6 +142,14 @@ function optionalComponentName(input, key) {
105
142
  return value;
106
143
  }
107
144
 
145
+ function normalizeApexlangInput(input) {
146
+ const normalized = { ...input };
147
+ for (const key of ["app_path", "db_connection_name", "workspace_name"]) {
148
+ if (typeof normalized[key] === "string") normalized[key] = normalized[key].trim();
149
+ }
150
+ return normalized;
151
+ }
152
+
108
153
  function addOption(args, name, value) {
109
154
  if (value !== undefined) {
110
155
  args.push(name, value);
@@ -263,6 +308,7 @@ export function buildApexlangCommand(input) {
263
308
  addOption(args, "--execution-mode", optionalText(input, "execution_mode"));
264
309
  addOption(args, "--apex-root", optionalPath(input, "apex_root"));
265
310
  addOption(args, "--compiler-oracle-home", optionalPath(input, "compiler_oracle_home"));
311
+ addFlag(args, "--supporting-objects", input.supporting_objects);
266
312
  return apexctl(args, [{ scriptPath: apexctlPath, args: probeArgs }]);
267
313
  }
268
314
 
@@ -303,7 +349,11 @@ export function buildApexlangImportCommand(input, importOptions = {}) {
303
349
  addOption(args, "--execution-mode", optionalText(input, "execution_mode"));
304
350
  addOption(args, "--apex-root", optionalPath(input, "apex_root"));
305
351
  addFlag(args, "--supporting-objects", input.supporting_objects);
306
- return apexctl(args, [{ scriptPath: apexctlPath, args: probeArgs }]);
352
+ return {
353
+ scriptPath: runtimeRoundtripPath,
354
+ args,
355
+ prelude: [{ scriptPath: apexctlPath, args: probeArgs }]
356
+ };
307
357
  }
308
358
 
309
359
  function abortError() {
@@ -441,11 +491,141 @@ async function assertWorkspaceProbeCannotFollowSymlinks(cwd) {
441
491
  await walk(resolve(cwd), 0);
442
492
  }
443
493
 
444
- async function assertDeploymentWorkspace(input, cwd) {
445
- if (!input.app_path ||
446
- !["runtime_validate", "runtime_preflight", "runtime_doctor"].includes(input.action)) {
494
+ function runtimeActionUsesDeployment(input) {
495
+ return Boolean(input.app_path) &&
496
+ ["runtime_validate", "runtime_preflight", "runtime_doctor"].includes(input.action);
497
+ }
498
+
499
+ function workspaceName(value) {
500
+ return String(value ?? "").trim();
501
+ }
502
+
503
+ function workspaceNamesMatch(left, right) {
504
+ return left.toUpperCase() === right.toUpperCase();
505
+ }
506
+
507
+ function stableJsonValue(value) {
508
+ if (Array.isArray(value)) return value.map(stableJsonValue);
509
+ if (!value || typeof value !== "object") return value;
510
+ return Object.fromEntries(
511
+ Object.keys(value)
512
+ .sort()
513
+ .map((key) => [key, stableJsonValue(value[key])])
514
+ );
515
+ }
516
+
517
+ function canonicalDeploymentBytes(contents) {
518
+ let deployment;
519
+ try {
520
+ deployment = JSON.parse(contents.toString("utf8"));
521
+ } catch (error) {
522
+ throw new Error(
523
+ `Cannot digest invalid deployments/default.json: ${error instanceof Error ? error.message : String(error)}`
524
+ );
525
+ }
526
+ if (!deployment || typeof deployment !== "object" || Array.isArray(deployment)) {
527
+ throw new Error("Cannot digest deployments/default.json unless it contains a JSON object");
528
+ }
529
+
530
+ // prepareRuntimeApp may inject this run-scoped field and reformat the JSON. The
531
+ // explicit workspace input is checked separately, so omit only that injected
532
+ // name while retaining every other deployment property in the content digest.
533
+ if (deployment.workspace && typeof deployment.workspace === "object" &&
534
+ !Array.isArray(deployment.workspace)) {
535
+ const workspace = { ...deployment.workspace };
536
+ delete workspace.name;
537
+ if (Object.keys(workspace).length === 0) {
538
+ delete deployment.workspace;
539
+ } else {
540
+ deployment.workspace = workspace;
541
+ }
542
+ }
543
+ return Buffer.from(JSON.stringify(stableJsonValue(deployment)), "utf8");
544
+ }
545
+
546
+ export async function computeApexlangAppDigest(appPath) {
547
+ const root = resolve(appPath);
548
+ const rootStats = await lstat(root);
549
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
550
+ throw new Error("APEXlang app digest requires a real application directory");
551
+ }
552
+
553
+ const hash = createHash("sha256");
554
+ hash.update("pi-apexlang-app-tree-v1\0");
555
+
556
+ async function walk(directory) {
557
+ const entries = (await readdir(directory, { withFileTypes: true }))
558
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
559
+ for (const entry of entries) {
560
+ const entryPath = resolve(directory, entry.name);
561
+ const relativePath = relative(root, entryPath).split(sep).join("/");
562
+ if (entry.isSymbolicLink()) {
563
+ throw new Error(`APEXlang app digest refuses symbolic links: ${entryPath}`);
564
+ }
565
+ if (entry.isDirectory()) {
566
+ hash.update(`directory\0${relativePath}\0`);
567
+ await walk(entryPath);
568
+ continue;
569
+ }
570
+ if (!entry.isFile()) {
571
+ throw new Error(`APEXlang app digest refuses non-regular files: ${entryPath}`);
572
+ }
573
+ const rawContents = await readFile(entryPath);
574
+ const contents = relativePath === "deployments/default.json"
575
+ ? canonicalDeploymentBytes(rawContents)
576
+ : rawContents;
577
+ hash.update(`file\0${relativePath}\0${contents.length}\0`);
578
+ hash.update(contents);
579
+ hash.update("\0");
580
+ }
581
+ }
582
+
583
+ await walk(root);
584
+ return hash.digest("hex");
585
+ }
586
+
587
+ function requireAppDigest(value, label = "expectedAppDigest") {
588
+ const digest = String(value ?? "").trim().toLowerCase();
589
+ if (!/^[a-f0-9]{64}$/.test(digest)) {
590
+ throw new Error(`${label} must be a 64-character SHA-256 app digest`);
591
+ }
592
+ return digest;
593
+ }
594
+
595
+ function commandWithAppPath(command, appPath) {
596
+ const args = [...command.args];
597
+ const appPathIndex = args.indexOf("--app-path");
598
+ if (appPathIndex >= 0) {
599
+ args[appPathIndex + 1] = appPath;
600
+ }
601
+ return { ...command, args };
602
+ }
603
+
604
+ async function clearStaleRuntimeEvidence(command, outputRoot) {
605
+ if (command.args[0] !== "runtime") return;
606
+ if (command.args[1] === "validate") {
607
+ await Promise.all([
608
+ rm(resolve(outputRoot, "logs/validation"), { recursive: true, force: true }),
609
+ rm(resolve(outputRoot, "logs/compat/sqlcl-warning-policy-validation.json"), { force: true })
610
+ ]);
447
611
  return;
448
612
  }
613
+ if (command.args[1] === "roundtrip") {
614
+ await Promise.all([
615
+ rm(resolve(outputRoot, "logs/runtime-run.json"), { force: true }),
616
+ rm(resolve(outputRoot, "logs/runtime-run.log"), { force: true }),
617
+ rm(resolve(outputRoot, "logs/compat/sqlcl-warning-policy-import.json"), { force: true }),
618
+ rm(resolve(outputRoot, "logs/compat/sqlcl-warning-policy-import-failure.json"), { force: true }),
619
+ rm(resolve(outputRoot, "logs/compat/sqlcl-warning-policy-import.log"), { force: true })
620
+ ]);
621
+ }
622
+ }
623
+
624
+ export async function prepareRuntimeApp(input, cwd, outputRoot, prepareOptions = {}) {
625
+ if (!runtimeActionUsesDeployment(input)) {
626
+ return { appPath: input.app_path ? resolve(cwd, input.app_path) : undefined, staged: false };
627
+ }
628
+
449
629
  const appPath = resolve(cwd, input.app_path);
450
630
  const deploymentPath = resolve(appPath, "deployments/default.json");
451
631
  let deployment;
@@ -456,16 +636,80 @@ async function assertDeploymentWorkspace(input, cwd) {
456
636
  `deployments/default.json is required before ${input.action}: ${error instanceof Error ? error.message : String(error)}`
457
637
  );
458
638
  }
459
- const declaredWorkspace = String(deployment?.workspace?.name ?? "").trim();
460
- const requestedWorkspace = String(input.workspace_name ?? "").trim();
461
- if (!declaredWorkspace || declaredWorkspace === "__REQUIRED_WORKSPACE_NAME__") {
639
+ if (!deployment || typeof deployment !== "object" || Array.isArray(deployment)) {
640
+ throw new Error("deployments/default.json must contain a JSON object");
641
+ }
642
+
643
+ const topLevelWorkspace = workspaceName(deployment?.workspace?.name);
644
+ const exportedWorkspace = workspaceName(deployment?.app?.workspace?.name);
645
+ const requestedWorkspace = workspaceName(input.workspace_name);
646
+ if (!requestedWorkspace) {
647
+ throw new Error(`workspace_name is required for ${input.action}`);
648
+ }
649
+ if (topLevelWorkspace && exportedWorkspace &&
650
+ !workspaceNamesMatch(topLevelWorkspace, exportedWorkspace)) {
651
+ throw new Error(
652
+ `deployments/default.json contains conflicting workspace names: ${topLevelWorkspace} and ${exportedWorkspace}`
653
+ );
654
+ }
655
+
656
+ const declaredWorkspace = topLevelWorkspace || exportedWorkspace;
657
+ if (declaredWorkspace === "__REQUIRED_WORKSPACE_NAME__") {
462
658
  throw new Error("deployments/default.json must declare the exact destination workspace.name");
463
659
  }
464
- if (declaredWorkspace.toUpperCase() !== requestedWorkspace.toUpperCase()) {
660
+ if (declaredWorkspace && !workspaceNamesMatch(declaredWorkspace, requestedWorkspace)) {
465
661
  throw new Error(
466
662
  `workspace_name ${requestedWorkspace} does not match deployments/default.json workspace.name ${declaredWorkspace}`
467
663
  );
468
664
  }
665
+
666
+ if (topLevelWorkspace && prepareOptions.forceStage !== true) {
667
+ return { appPath, staged: false, workspaceSource: "workspace.name" };
668
+ }
669
+
670
+ const stageKey = createHash("sha256")
671
+ .update(`${appPath}\0${requestedWorkspace.toUpperCase()}`)
672
+ .digest("hex")
673
+ .slice(0, 16);
674
+ const stagedAppPath = resolve(outputRoot, "runtime-apps", stageKey);
675
+ if (isWithin(appPath, stagedAppPath)) {
676
+ throw new Error("APEXLANG_OUTPUT_ROOT must not be inside the application tree");
677
+ }
678
+ const [appRealPath, outputRealAncestor] = await Promise.all([
679
+ realpath(appPath),
680
+ nearestExistingRealPath(resolve(outputRoot))
681
+ ]);
682
+ if (isWithin(appRealPath, outputRealAncestor)) {
683
+ throw new Error("APEXLANG_OUTPUT_ROOT must not resolve inside the application tree");
684
+ }
685
+ await rm(stagedAppPath, { recursive: true, force: true });
686
+ await mkdir(dirname(stagedAppPath), { recursive: true });
687
+ await cp(appPath, stagedAppPath, {
688
+ recursive: true,
689
+ force: false,
690
+ errorOnExist: true,
691
+ preserveTimestamps: true
692
+ });
693
+ deployment.workspace = {
694
+ ...(deployment.workspace && typeof deployment.workspace === "object"
695
+ ? deployment.workspace
696
+ : {}),
697
+ name: requestedWorkspace
698
+ };
699
+ await writeFile(
700
+ resolve(stagedAppPath, "deployments/default.json"),
701
+ `${JSON.stringify(deployment, null, 2)}\n`,
702
+ "utf8"
703
+ );
704
+ return {
705
+ appPath: stagedAppPath,
706
+ staged: true,
707
+ workspaceSource: topLevelWorkspace
708
+ ? "workspace.name"
709
+ : exportedWorkspace
710
+ ? "app.workspace.name"
711
+ : "explicit_workspace_name"
712
+ };
469
713
  }
470
714
 
471
715
  function isDirectChild(root, candidate) {
@@ -594,6 +838,48 @@ async function killProcessTree(child) {
594
838
  });
595
839
  }
596
840
 
841
+ async function killPosixProcessGroup(groupId) {
842
+ try {
843
+ process.kill(-groupId, "SIGKILL");
844
+ } catch (error) {
845
+ if (error?.code === "ESRCH") return;
846
+ throw error;
847
+ }
848
+ const deadline = Date.now() + 2_000;
849
+ while (Date.now() < deadline) {
850
+ try {
851
+ process.kill(-groupId, 0);
852
+ } catch (error) {
853
+ if (error?.code === "ESRCH") return;
854
+ throw error;
855
+ }
856
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 20));
857
+ }
858
+ }
859
+
860
+ async function killPtyProcessTree(child, ptyProcessGroupPromise) {
861
+ if (!child.pid || process.platform === "win32") {
862
+ await killProcessTree(child);
863
+ return;
864
+ }
865
+
866
+ // The helper publishes its forkpty child PGID on a private pipe before the
867
+ // adapter sends any SQLcl command. Kill that group directly; never rely on
868
+ // the proxy being scheduled in time to forward a signal.
869
+ try {
870
+ child.kill("SIGCONT");
871
+ } catch {
872
+ // The proxy may already have exited.
873
+ }
874
+ const ptyProcessGroupId = await ptyProcessGroupPromise;
875
+ if (Number.isInteger(ptyProcessGroupId) && ptyProcessGroupId > 1) {
876
+ await killPosixProcessGroup(ptyProcessGroupId);
877
+ }
878
+ // When no valid PGID was published, the helper contract guarantees that it
879
+ // killed/reaped any forkpty child before closing the control pipe.
880
+ await killProcessTree(child);
881
+ }
882
+
597
883
  export function executeProcessTree(executable, args, options) {
598
884
  if (options.signal?.aborted) {
599
885
  return Promise.reject(abortError());
@@ -605,7 +891,7 @@ export function executeProcessTree(executable, args, options) {
605
891
  detached: process.platform !== "win32",
606
892
  env: options.env,
607
893
  shell: false,
608
- stdio: ["ignore", "pipe", "pipe"],
894
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
609
895
  windowsHide: true
610
896
  });
611
897
  const stdoutChunks = [];
@@ -617,6 +903,13 @@ export function executeProcessTree(executable, args, options) {
617
903
  let killPromise = Promise.resolve();
618
904
  let settled = false;
619
905
 
906
+ if (options.input !== undefined) {
907
+ child.stdin.on("error", (error) => {
908
+ if (error?.code !== "EPIPE") processError = error;
909
+ });
910
+ child.stdin.end(String(options.input));
911
+ }
912
+
620
913
  const terminate = (reason) => {
621
914
  if (terminationReason) return;
622
915
  terminationReason = reason;
@@ -674,6 +967,312 @@ export function executeProcessTree(executable, args, options) {
674
967
  });
675
968
  }
676
969
 
970
+ export function executeSqlclValidationThenImport(executable, args, options) {
971
+ if (options.signal?.aborted) {
972
+ return Promise.reject(abortError());
973
+ }
974
+ if (process.platform === "win32") {
975
+ return Promise.reject(new Error(
976
+ "Warning-compatible import requires an ordered merged SQLcl output stream and is unavailable on Windows"
977
+ ));
978
+ }
979
+
980
+ const markerToken = randomUUID().replaceAll("-", "").toUpperCase();
981
+ const sessionReadyMarker = `__PI_APEXLANG_SESSION_READY_${markerToken}__`;
982
+ const validateDoneMarker = `__PI_APEXLANG_VALIDATE_DONE_${markerToken}__`;
983
+ const importDoneMarker = `__PI_APEXLANG_IMPORT_DONE_${markerToken}__`;
984
+
985
+ return new Promise((resolveProcess, rejectProcess) => {
986
+ const child = spawn("python3", [
987
+ sqlclPtyProxyPath,
988
+ executable,
989
+ ...args
990
+ ], {
991
+ cwd: options.cwd,
992
+ detached: process.platform !== "win32",
993
+ env: {
994
+ ...options.env,
995
+ TERM: "dumb",
996
+ COLUMNS: "120",
997
+ LINES: "40",
998
+ PYTHONDONTWRITEBYTECODE: "1"
999
+ },
1000
+ shell: false,
1001
+ stdio: ["pipe", "pipe", "pipe", "pipe"],
1002
+ windowsHide: true
1003
+ });
1004
+ const stdoutChunks = [];
1005
+ const stderrChunks = [];
1006
+ const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
1007
+ let bufferedBytes = 0;
1008
+ let terminationReason;
1009
+ let processError;
1010
+ let killPromise = Promise.resolve();
1011
+ let settled = false;
1012
+ let sessionSetupTimer;
1013
+ let sessionSetupSent = false;
1014
+ let sessionReady = false;
1015
+ let validationSent = false;
1016
+ let validationStartIndex = -1;
1017
+ let validationDecided = false;
1018
+ let validationAccepted = false;
1019
+ let validationEvidence = { accepted: false, reason: "validation_marker_missing" };
1020
+ let importSent = false;
1021
+ let ptyProcessGroupReady = false;
1022
+ let ptyControlBuffer = "";
1023
+ let ptyControlSettled = false;
1024
+ let resolvePtyProcessGroup;
1025
+ const ptyProcessGroupPromise = new Promise((resolveGroup) => {
1026
+ resolvePtyProcessGroup = resolveGroup;
1027
+ });
1028
+ const settlePtyProcessGroup = (groupId) => {
1029
+ if (ptyControlSettled) return;
1030
+ ptyControlSettled = true;
1031
+ if (Number.isInteger(groupId) && groupId > 1 && groupId !== child.pid) {
1032
+ ptyProcessGroupReady = true;
1033
+ resolvePtyProcessGroup(groupId);
1034
+ return;
1035
+ }
1036
+ resolvePtyProcessGroup(undefined);
1037
+ };
1038
+
1039
+ const terminate = (reason) => {
1040
+ if (terminationReason) return;
1041
+ terminationReason = reason;
1042
+ killPromise = killPtyProcessTree(child, ptyProcessGroupPromise);
1043
+ };
1044
+ const onAbort = () => terminate("aborted");
1045
+ options.signal?.addEventListener("abort", onAbort, { once: true });
1046
+ if (options.signal?.aborted) onAbort();
1047
+ const timeout = options.timeoutMs > 0
1048
+ ? setTimeout(() => terminate("timed out"), options.timeoutMs)
1049
+ : undefined;
1050
+ timeout?.unref();
1051
+
1052
+ child.stdin.on("error", (error) => {
1053
+ if (error?.code !== "EPIPE") processError = error;
1054
+ });
1055
+
1056
+ const endSqlclInput = (commands) => {
1057
+ if (terminationReason || child.stdin.destroyed) return;
1058
+ child.stdin.end([...commands, ""].join("\n"));
1059
+ };
1060
+
1061
+ const scheduleSessionSetup = () => {
1062
+ if (!ptyProcessGroupReady || sessionSetupSent || validationDecided ||
1063
+ terminationReason || child.stdin.destroyed) {
1064
+ return;
1065
+ }
1066
+ if (sessionSetupTimer) clearTimeout(sessionSetupTimer);
1067
+ sessionSetupTimer = setTimeout(() => {
1068
+ sessionSetupTimer = undefined;
1069
+ if (!ptyProcessGroupReady || sessionSetupSent || validationDecided ||
1070
+ terminationReason || child.stdin.destroyed) {
1071
+ return;
1072
+ }
1073
+ const startupOutput = Buffer.concat(stdoutChunks).toString("utf8");
1074
+ if (containsSqlclHardFailure(startupOutput)) {
1075
+ validationDecided = true;
1076
+ validationEvidence = { accepted: false, reason: "sqlcl_session_start_failed" };
1077
+ endSqlclInput(["exit failure rollback"]);
1078
+ return;
1079
+ }
1080
+ sessionSetupSent = true;
1081
+ child.stdin.write([
1082
+ 'set sqlprompt ""',
1083
+ `prompt ${sessionReadyMarker}`,
1084
+ ""
1085
+ ].join("\n"));
1086
+ }, 250);
1087
+ };
1088
+
1089
+ const inspectSqlclOutput = () => {
1090
+ if (!ptyProcessGroupReady || terminationReason || child.stdin.destroyed) return;
1091
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
1092
+ const normalizedStdout = normalizeInteractiveSqlclOutput(stdout);
1093
+
1094
+ if (!sessionSetupSent) {
1095
+ if (containsSqlclHardFailure(normalizedStdout)) {
1096
+ validationDecided = true;
1097
+ validationEvidence = { accepted: false, reason: "sqlcl_session_start_failed" };
1098
+ endSqlclInput(["exit failure rollback"]);
1099
+ return;
1100
+ }
1101
+ scheduleSessionSetup();
1102
+ return;
1103
+ }
1104
+
1105
+ const readyMarkerSpan = exactSqlclMarkerSpan(
1106
+ normalizedStdout,
1107
+ sessionReadyMarker,
1108
+ { allowPromptPrefix: true }
1109
+ );
1110
+ if (!sessionReady) {
1111
+ if (!readyMarkerSpan) return;
1112
+ const startupOutput = normalizedStdout.slice(0, readyMarkerSpan.start);
1113
+ if (containsSqlclHardFailure(startupOutput)) {
1114
+ validationDecided = true;
1115
+ validationEvidence = { accepted: false, reason: "sqlcl_session_start_failed" };
1116
+ endSqlclInput(["exit failure rollback"]);
1117
+ return;
1118
+ }
1119
+ sessionReady = true;
1120
+ validationSent = true;
1121
+ validationStartIndex = readyMarkerSpan.end;
1122
+ child.stdin.write([
1123
+ "whenever sqlerror exit failure rollback",
1124
+ "whenever oserror exit failure rollback",
1125
+ options.validateCommand,
1126
+ `prompt ${validateDoneMarker}`,
1127
+ ""
1128
+ ].join("\n"));
1129
+ return;
1130
+ }
1131
+
1132
+ if (validationDecided) return;
1133
+ const validateMarkerSpan = exactSqlclMarkerSpan(normalizedStdout, validateDoneMarker);
1134
+ if (!validateMarkerSpan) return;
1135
+ validationDecided = true;
1136
+ const validationOutput = normalizedStdout.slice(
1137
+ validationStartIndex,
1138
+ validateMarkerSpan.start
1139
+ );
1140
+ const currentStderr = Buffer.concat(stderrChunks).toString("utf8");
1141
+ validationEvidence = classifyWarningCompatibleSqlclValidation(
1142
+ [validationOutput, currentStderr].filter(Boolean).join("\n")
1143
+ );
1144
+ validationAccepted = validationEvidence.accepted;
1145
+ if (!validationAccepted) {
1146
+ endSqlclInput(["exit failure rollback"]);
1147
+ return;
1148
+ }
1149
+ importSent = true;
1150
+ endSqlclInput([
1151
+ options.importCommand,
1152
+ `prompt ${importDoneMarker}`,
1153
+ "exit success commit"
1154
+ ]);
1155
+ };
1156
+
1157
+ const capture = (chunks, inspectOutput = false) => (chunk) => {
1158
+ if (terminationReason === "output limit exceeded") return;
1159
+ bufferedBytes += chunk.length;
1160
+ if (bufferedBytes > maxBuffer) {
1161
+ terminate("output limit exceeded");
1162
+ return;
1163
+ }
1164
+ chunks.push(chunk);
1165
+ if (inspectOutput) inspectSqlclOutput();
1166
+ };
1167
+ const ptyControlStream = child.stdio[3];
1168
+ ptyControlStream.setEncoding("utf8");
1169
+ ptyControlStream.on("data", (chunk) => {
1170
+ if (ptyControlSettled) return;
1171
+ ptyControlBuffer += chunk;
1172
+ if (ptyControlBuffer.length > 32) {
1173
+ processError = new Error("PTY proxy returned invalid process-group evidence");
1174
+ settlePtyProcessGroup(undefined);
1175
+ terminate("PTY control channel failed");
1176
+ }
1177
+ });
1178
+ ptyControlStream.once("error", (error) => {
1179
+ processError = error;
1180
+ settlePtyProcessGroup(undefined);
1181
+ terminate("PTY control channel failed");
1182
+ });
1183
+ ptyControlStream.once("end", () => {
1184
+ if (ptyControlSettled) return;
1185
+ const match = /^([1-9]\d{0,9})\n$/.exec(ptyControlBuffer);
1186
+ const groupId = match ? Number(match[1]) : undefined;
1187
+ if (!Number.isSafeInteger(groupId) || groupId <= 1 || groupId === child.pid) {
1188
+ processError = new Error("PTY proxy returned invalid process-group evidence");
1189
+ settlePtyProcessGroup(undefined);
1190
+ terminate("PTY control channel failed");
1191
+ return;
1192
+ }
1193
+ settlePtyProcessGroup(groupId);
1194
+ // Process-group proof authorizes safe cleanup, but it is not a SQLcl
1195
+ // readiness signal. Sending input before SQLcl emits terminal output
1196
+ // can be discarded by its startup terminal initialization.
1197
+ if (stdoutChunks.length > 0) inspectSqlclOutput();
1198
+ });
1199
+ child.stdout.on("data", capture(stdoutChunks, true));
1200
+ child.stderr.on("data", capture(stderrChunks));
1201
+ child.once("error", (error) => {
1202
+ processError = error;
1203
+ });
1204
+ child.once("close", async (code) => {
1205
+ if (settled) return;
1206
+ settled = true;
1207
+ if (timeout) clearTimeout(timeout);
1208
+ if (sessionSetupTimer) clearTimeout(sessionSetupTimer);
1209
+ options.signal?.removeEventListener("abort", onAbort);
1210
+ await killPromise;
1211
+
1212
+ if (terminationReason === "aborted") {
1213
+ rejectProcess(abortError());
1214
+ return;
1215
+ }
1216
+
1217
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
1218
+ const normalizedStdout = normalizeInteractiveSqlclOutput(stdout);
1219
+ const capturedStderr = Buffer.concat(stderrChunks).toString("utf8");
1220
+ const readyMarkerSpan = exactSqlclMarkerSpan(
1221
+ normalizedStdout,
1222
+ sessionReadyMarker,
1223
+ { allowPromptPrefix: true }
1224
+ );
1225
+ const validateMarkerSpan = exactSqlclMarkerSpan(normalizedStdout, validateDoneMarker);
1226
+ const importMarkerSpan = exactSqlclMarkerSpan(normalizedStdout, importDoneMarker);
1227
+ const validationOutput = validateMarkerSpan && validationStartIndex >= 0
1228
+ ? normalizedStdout.slice(validationStartIndex, validateMarkerSpan.start)
1229
+ : normalizedStdout;
1230
+ const importOutput = validateMarkerSpan
1231
+ ? normalizedStdout.slice(
1232
+ validateMarkerSpan.end,
1233
+ importMarkerSpan?.start
1234
+ )
1235
+ : "";
1236
+ const diagnostic = terminationReason
1237
+ ? `APEXlang process ${terminationReason}.`
1238
+ : processError instanceof Error
1239
+ ? processError.message
1240
+ : "";
1241
+ const stderr = [capturedStderr.trimEnd(), diagnostic].filter(Boolean).join("\n");
1242
+ const markersAreOrdered = Boolean(
1243
+ readyMarkerSpan &&
1244
+ validateMarkerSpan &&
1245
+ importMarkerSpan &&
1246
+ readyMarkerSpan.start < validateMarkerSpan.start &&
1247
+ validateMarkerSpan.start < importMarkerSpan.start
1248
+ );
1249
+ const importCompleted = importSent && markersAreOrdered;
1250
+ const hardFailureDetected = containsSqlclHardFailure(
1251
+ [stdout, stderr].filter(Boolean).join("\n")
1252
+ );
1253
+ resolveProcess({
1254
+ ok: code === 0 && !terminationReason && !processError && ptyProcessGroupReady &&
1255
+ validationAccepted && importCompleted && !hardFailureDetected,
1256
+ code: Number.isInteger(code) && !terminationReason ? code : 1,
1257
+ stdout,
1258
+ stderr,
1259
+ validationAccepted,
1260
+ validationEvidence,
1261
+ sessionReady,
1262
+ validationSent,
1263
+ importSent,
1264
+ importCompleted,
1265
+ hardFailureDetected,
1266
+ validationOutput,
1267
+ importOutput,
1268
+ orderedMergedOutput: true,
1269
+ ptyBacked: true,
1270
+ ptyProcessGroupReady
1271
+ });
1272
+ });
1273
+ });
1274
+ }
1275
+
677
1276
  function invoke(command, options) {
678
1277
  return executeProcessTree(process.execPath, [command.scriptPath, ...command.args], {
679
1278
  cwd: options.cwd,
@@ -688,6 +1287,765 @@ function invoke(command, options) {
688
1287
  });
689
1288
  }
690
1289
 
1290
+ function parseJsonRecord(value) {
1291
+ try {
1292
+ const parsed = JSON.parse(String(value ?? ""));
1293
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
1294
+ } catch {
1295
+ return undefined;
1296
+ }
1297
+ }
1298
+
1299
+ function countMatches(value, pattern) {
1300
+ return [...String(value).matchAll(pattern)].length;
1301
+ }
1302
+
1303
+ function normalizeInteractiveSqlclOutput(output) {
1304
+ return stripVTControlCharacters(String(output))
1305
+ .replace(/\r+\n/g, "\n")
1306
+ .replace(/\r/g, "\n");
1307
+ }
1308
+
1309
+ function escapeRegularExpression(value) {
1310
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1311
+ }
1312
+
1313
+ function exactSqlclMarkerSpan(output, marker, options = {}) {
1314
+ const normalizedOutput = normalizeInteractiveSqlclOutput(output);
1315
+ const escapedMarker = escapeRegularExpression(marker);
1316
+ const promptPrefix = options.allowPromptPrefix === true
1317
+ ? "[^\\r\\n]{0,256}"
1318
+ : "(?:SQL>[^\\S\\r\\n]*)?";
1319
+ const match = new RegExp(
1320
+ `(^|\\n)${promptPrefix}${escapedMarker}(?=\\n|$)`
1321
+ ).exec(normalizedOutput);
1322
+ if (!match) return undefined;
1323
+ return {
1324
+ start: match.index + match[1].length,
1325
+ end: match.index + match[0].length
1326
+ };
1327
+ }
1328
+
1329
+ function transcriptSection(transcript, label) {
1330
+ const header = `## ${label}`;
1331
+ const lines = String(transcript).split(/\r?\n/);
1332
+ const headerIndex = lines.findIndex((line) => line === header);
1333
+ if (headerIndex < 0) return "";
1334
+ const nextHeaderIndex = lines.findIndex(
1335
+ (line, index) => index > headerIndex && line.startsWith("## ")
1336
+ );
1337
+ return lines.slice(headerIndex + 1, nextHeaderIndex < 0 ? undefined : nextHeaderIndex).join("\n");
1338
+ }
1339
+
1340
+ function containsSqlclHardFailure(output) {
1341
+ const normalizedOutput = stripVTControlCharacters(String(output));
1342
+ return SQLCL_HARD_FAILURE_PATTERNS.some((pattern) => pattern.test(normalizedOutput));
1343
+ }
1344
+
1345
+ export function classifyWarningCompatibleSqlclValidation(output) {
1346
+ const warningCount = countMatches(output, SQLCL_WARNING_HEADER_PATTERN);
1347
+ const validationSuccessCount = countMatches(output, /^Validation successful\.\s*$/gim);
1348
+ if (warningCount !== 1 || validationSuccessCount !== 1) {
1349
+ return {
1350
+ accepted: false,
1351
+ reason: "warning_success_markers_not_exact",
1352
+ warningCount,
1353
+ validationSuccessCount
1354
+ };
1355
+ }
1356
+ if (containsSqlclHardFailure(output)) {
1357
+ return {
1358
+ accepted: false,
1359
+ reason: "hard_failure_marker_present",
1360
+ warningCount,
1361
+ validationSuccessCount
1362
+ };
1363
+ }
1364
+ return {
1365
+ accepted: true,
1366
+ reason: "sqlcl_explicit_validation_success_with_compile_warnings",
1367
+ warningCount,
1368
+ validationSuccessCount,
1369
+ outputSha256: createHash("sha256").update(String(output)).digest("hex")
1370
+ };
1371
+ }
1372
+
1373
+ export function classifyWarningOnlyRuntimePayload(
1374
+ payload,
1375
+ transcript,
1376
+ expectedImportIntent = "validate-only"
1377
+ ) {
1378
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1379
+ return { accepted: false, reason: "missing_runtime_payload" };
1380
+ }
1381
+ if (payload.failure_class !== "live_validate_failed" || payload.blocking_reason !== "live_validate_failed") {
1382
+ return { accepted: false, reason: "unexpected_failure_class" };
1383
+ }
1384
+ if (payload.import_intent_choice !== expectedImportIntent) {
1385
+ return { accepted: false, reason: "unexpected_import_intent" };
1386
+ }
1387
+ const phases = Array.isArray(payload.phase_reports) ? payload.phase_reports : [];
1388
+ const livePhaseIndex = phases.findIndex((phase) => phase?.phase === "live_validate");
1389
+ if (livePhaseIndex < 0) {
1390
+ return { accepted: false, reason: "missing_live_validate_phase" };
1391
+ }
1392
+ const livePhase = phases[livePhaseIndex];
1393
+ if (livePhase?.status !== "fail" || livePhase?.failure_class !== "live_validate_failed") {
1394
+ return { accepted: false, reason: "unexpected_live_validate_phase" };
1395
+ }
1396
+ if (phases.slice(0, livePhaseIndex).some((phase) => phase?.status !== "pass")) {
1397
+ return { accepted: false, reason: "earlier_phase_did_not_pass" };
1398
+ }
1399
+
1400
+ const attemptLabel = "roundtrip_sql_name_alias";
1401
+ const attempt = transcriptSection(transcript, attemptLabel);
1402
+ if (!attempt) {
1403
+ return { accepted: false, reason: "preferred_sqlcl_attempt_missing" };
1404
+ }
1405
+ const sqlclEvidence = classifyWarningCompatibleSqlclValidation(attempt);
1406
+ if (!sqlclEvidence.accepted) return sqlclEvidence;
1407
+ return {
1408
+ accepted: true,
1409
+ reason: "sqlcl_explicit_validation_success_with_compile_warnings",
1410
+ attemptLabel,
1411
+ warningCount: sqlclEvidence.warningCount,
1412
+ validationSuccessCount: sqlclEvidence.validationSuccessCount,
1413
+ attemptSha256: createHash("sha256").update(attempt).digest("hex")
1414
+ };
1415
+ }
1416
+
1417
+ async function resolveOutputArtifact(outputRoot, candidate, label) {
1418
+ const outputPath = resolve(outputRoot);
1419
+ const artifactPath = resolve(String(candidate ?? ""));
1420
+ if (!candidate || !isWithin(outputPath, artifactPath)) {
1421
+ throw new Error(`${label} must stay inside APEXLANG_OUTPUT_ROOT`);
1422
+ }
1423
+ const [outputRealPath, artifactRealPath] = await Promise.all([
1424
+ realpath(outputPath),
1425
+ realpath(artifactPath)
1426
+ ]);
1427
+ if (!isWithin(outputRealPath, artifactRealPath)) {
1428
+ throw new Error(`${label} must not escape APEXLANG_OUTPUT_ROOT through a symbolic link`);
1429
+ }
1430
+ return artifactRealPath;
1431
+ }
1432
+
1433
+ async function writeCompatibilityRecord(outputRoot, filename, record) {
1434
+ const compatibilityDirectory = resolve(outputRoot, "logs/compat");
1435
+ await mkdir(compatibilityDirectory, { recursive: true });
1436
+ const recordPath = resolve(compatibilityDirectory, filename);
1437
+ await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
1438
+ return recordPath;
1439
+ }
1440
+
1441
+ export async function proveWarningOnlyProblemsAreDiagnostics(payload, result) {
1442
+ const blockingReasons = Array.isArray(payload.blocking_reasons)
1443
+ ? payload.blocking_reasons.map(String)
1444
+ : [];
1445
+ const hasProblemsBlocker = blockingReasons.includes(
1446
+ "problems.json contains unresolved validation problems."
1447
+ );
1448
+ const exactCount = (value) => {
1449
+ if (typeof value === "number") {
1450
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
1451
+ }
1452
+ if (typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value.trim())) {
1453
+ const parsed = Number(value.trim());
1454
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
1455
+ }
1456
+ return undefined;
1457
+ };
1458
+ const outerProblemCount = exactCount(payload.problem_count);
1459
+ const outerUnresolvedCount = exactCount(payload.unresolved_count);
1460
+ if (outerProblemCount === undefined || outerUnresolvedCount === undefined) {
1461
+ return { accepted: false, reason: "runtime_problem_counts_are_invalid" };
1462
+ }
1463
+ if (outerProblemCount === 0 && outerUnresolvedCount === 0) {
1464
+ if (hasProblemsBlocker) {
1465
+ return { accepted: false, reason: "problem_blocker_conflicts_with_zero_counts" };
1466
+ }
1467
+ const zeroProblemsCandidate = payload.artifacts?.problems_path;
1468
+ if (!zeroProblemsCandidate) {
1469
+ return { accepted: false, reason: "problems_artifact_missing_for_zero_counts" };
1470
+ }
1471
+ const zeroProblemsPath = await resolveOutputArtifact(
1472
+ result.outputRoot,
1473
+ zeroProblemsCandidate,
1474
+ "problems_path"
1475
+ );
1476
+ const rawZeroProblems = await readFile(zeroProblemsPath, "utf8");
1477
+ const zeroProblemsPayload = parseJsonRecord(rawZeroProblems);
1478
+ const zeroProblems = zeroProblemsPayload?.problems;
1479
+ if (!Array.isArray(zeroProblems) || zeroProblems.length !== 0 ||
1480
+ exactCount(zeroProblemsPayload?.problem_count) !== 0 ||
1481
+ exactCount(zeroProblemsPayload?.unresolved_count) !== 0) {
1482
+ return { accepted: false, reason: "zero_problem_counts_conflict_with_artifact" };
1483
+ }
1484
+ return {
1485
+ accepted: true,
1486
+ problemCount: 0,
1487
+ problemsPath: zeroProblemsPath,
1488
+ problemsSha256: createHash("sha256").update(rawZeroProblems).digest("hex")
1489
+ };
1490
+ }
1491
+ if (outerProblemCount === 0 || outerUnresolvedCount === 0) {
1492
+ return { accepted: false, reason: "runtime_problem_counts_are_inconsistent" };
1493
+ }
1494
+ const problemsPath = await resolveOutputArtifact(
1495
+ result.outputRoot,
1496
+ payload.artifacts?.problems_path,
1497
+ "problems_path"
1498
+ );
1499
+ const rawProblems = await readFile(problemsPath, "utf8");
1500
+ const problemsPayload = parseJsonRecord(rawProblems);
1501
+ const problems = Array.isArray(problemsPayload?.problems) ? problemsPayload.problems : [];
1502
+ const declaredProblemCount = exactCount(problemsPayload?.problem_count);
1503
+ const declaredUnresolvedCount = exactCount(problemsPayload?.unresolved_count);
1504
+ const warningsOnly = problems.length > 0 && problems.every((problem) =>
1505
+ problem?.source === "apex_validate" &&
1506
+ String(problem?.severity ?? "").toLowerCase() === "warning" &&
1507
+ /^Warning:\s/.test(String(problem?.message ?? "")) &&
1508
+ !containsSqlclHardFailure(problem?.message)
1509
+ );
1510
+ if (!warningsOnly ||
1511
+ declaredProblemCount !== problems.length ||
1512
+ declaredUnresolvedCount !== problems.length ||
1513
+ outerProblemCount !== problems.length ||
1514
+ outerUnresolvedCount !== problems.length) {
1515
+ return { accepted: false, reason: "problems_artifact_is_not_warning_only" };
1516
+ }
1517
+ return {
1518
+ accepted: true,
1519
+ problemCount: problems.length,
1520
+ problemsPath,
1521
+ problemsSha256: createHash("sha256").update(rawProblems).digest("hex")
1522
+ };
1523
+ }
1524
+
1525
+ async function proveWarningOnlyValidationIdentity(
1526
+ input,
1527
+ result,
1528
+ payload,
1529
+ roundtripPayload,
1530
+ roundtripReportPath,
1531
+ transcriptPath
1532
+ ) {
1533
+ const runtimeAppPathValue = result.runtimeApp?.appPath;
1534
+ if (!runtimeAppPathValue) throw new Error("runtime app path is missing");
1535
+ const runtimeAppPath = await realpath(resolve(runtimeAppPathValue));
1536
+ for (const candidate of [
1537
+ roundtripPayload.final_app_path,
1538
+ roundtripPayload.temp_app_path,
1539
+ roundtripPayload.frozen_preflight_facts?.app_path
1540
+ ]) {
1541
+ if (!candidate || await realpath(resolve(candidate)) !== runtimeAppPath) {
1542
+ throw new Error("runtime evidence app path does not match the current runtime app");
1543
+ }
1544
+ }
1545
+ if (roundtripPayload.report_path &&
1546
+ await realpath(resolve(roundtripPayload.report_path)) !== roundtripReportPath) {
1547
+ throw new Error("runtime report path does not match the current artifact");
1548
+ }
1549
+ if (roundtripPayload.transcript_path &&
1550
+ await realpath(resolve(roundtripPayload.transcript_path)) !== transcriptPath) {
1551
+ throw new Error("runtime transcript path does not match the current artifact");
1552
+ }
1553
+ const outerTimestamp = Date.parse(String(payload.timestamp ?? ""));
1554
+ const roundtripTimestamp = Date.parse(String(roundtripPayload.timestamp ?? ""));
1555
+ if (!Number.isFinite(outerTimestamp) || !Number.isFinite(roundtripTimestamp) ||
1556
+ Math.abs(outerTimestamp - roundtripTimestamp) > 5_000) {
1557
+ throw new Error("runtime artifacts do not share the same validation timestamp");
1558
+ }
1559
+ const sourceId = Number(roundtripPayload.source_application_id);
1560
+ const frozen = roundtripPayload.frozen_preflight_facts;
1561
+ if (!Number.isInteger(sourceId) || sourceId <= 0 ||
1562
+ Number(frozen?.requested_application_id) !== sourceId ||
1563
+ frozen?.execution_mode_selected !== "path" ||
1564
+ frozen?.db_connection_name !== input.db_connection_name ||
1565
+ roundtripPayload.db_connection_name !== input.db_connection_name ||
1566
+ !sameName(frozen?.requested_application_alias, roundtripPayload.source_application_alias)) {
1567
+ throw new Error("runtime evidence source identity does not match frozen preflight facts");
1568
+ }
1569
+ const deployment = parseJsonRecord(
1570
+ await readFile(resolve(runtimeAppPath, "deployments/default.json"), "utf8")
1571
+ );
1572
+ const deploymentWorkspace = workspaceName(
1573
+ deployment?.workspace?.name ?? deployment?.app?.workspace?.name
1574
+ );
1575
+ if (Number(deployment?.app?.id) !== sourceId ||
1576
+ !workspaceNamesMatch(deploymentWorkspace, input.workspace_name)) {
1577
+ throw new Error("runtime evidence does not match staged deployment identity");
1578
+ }
1579
+ return {
1580
+ runtimeAppPath,
1581
+ sourceId,
1582
+ sourceAlias: roundtripPayload.source_application_alias,
1583
+ timestampDeltaMs: Math.abs(outerTimestamp - roundtripTimestamp)
1584
+ };
1585
+ }
1586
+
1587
+ async function normalizeWarningOnlyValidation(input, result) {
1588
+ input = normalizeApexlangInput(input);
1589
+ if (result.ok || input.action !== "runtime_validate") return result;
1590
+ const payload = parseJsonRecord(result.stdout);
1591
+ const artifacts = payload?.artifacts;
1592
+ if (!artifacts || typeof artifacts !== "object") return result;
1593
+
1594
+ let roundtripReportPath;
1595
+ let transcriptPath;
1596
+ try {
1597
+ [roundtripReportPath, transcriptPath] = await Promise.all([
1598
+ resolveOutputArtifact(
1599
+ result.outputRoot,
1600
+ artifacts.roundtrip_report_path,
1601
+ "roundtrip_report_path"
1602
+ ),
1603
+ resolveOutputArtifact(
1604
+ result.outputRoot,
1605
+ artifacts.validation_transcript_path,
1606
+ "validation_transcript_path"
1607
+ )
1608
+ ]);
1609
+ } catch {
1610
+ return result;
1611
+ }
1612
+
1613
+ const [roundtripPayload, transcript] = await Promise.all([
1614
+ readFile(roundtripReportPath, "utf8").then(parseJsonRecord),
1615
+ readFile(transcriptPath, "utf8")
1616
+ ]);
1617
+ const evidence = classifyWarningOnlyRuntimePayload(roundtripPayload, transcript, "validate-only");
1618
+ if (!evidence.accepted) return result;
1619
+ if (roundtripPayload.db_connection_name !== input.db_connection_name) return result;
1620
+ const originalBlockingReasons = Array.isArray(payload.blocking_reasons)
1621
+ ? payload.blocking_reasons.map(String)
1622
+ : [];
1623
+ if (originalBlockingReasons.some(
1624
+ (reason) => !COMPATIBLE_VALIDATION_BLOCKING_REASONS.has(reason)
1625
+ )) {
1626
+ return result;
1627
+ }
1628
+ let identityEvidence;
1629
+ let problemEvidence;
1630
+ try {
1631
+ [identityEvidence, problemEvidence] = await Promise.all([
1632
+ proveWarningOnlyValidationIdentity(
1633
+ input,
1634
+ result,
1635
+ payload,
1636
+ roundtripPayload,
1637
+ roundtripReportPath,
1638
+ transcriptPath
1639
+ ),
1640
+ proveWarningOnlyProblemsAreDiagnostics(payload, result)
1641
+ ]);
1642
+ } catch {
1643
+ return result;
1644
+ }
1645
+ if (!problemEvidence.accepted) return result;
1646
+ const diagnosticReasons = originalBlockingReasons.filter(
1647
+ (reason) => reason !== "Live APEX validation did not pass or did not produce pass evidence."
1648
+ );
1649
+
1650
+ const compatibilityRecord = {
1651
+ status: "accepted",
1652
+ policy: "sqlcl_explicit_validation_success_with_compile_warnings",
1653
+ timestamp: new Date().toISOString(),
1654
+ db_connection_name: input.db_connection_name,
1655
+ workspace_name: input.workspace_name,
1656
+ original_live_check_status: payload.live_check_status,
1657
+ original_validation_status: payload.validation_status,
1658
+ original_unresolved_count: payload.unresolved_count,
1659
+ original_blocking_reasons: originalBlockingReasons,
1660
+ diagnostic_reasons: diagnosticReasons,
1661
+ validation_identity: identityEvidence,
1662
+ warning_only_problems: problemEvidence,
1663
+ roundtrip_report_path: roundtripReportPath,
1664
+ transcript_path: transcriptPath,
1665
+ transcript_sha256: createHash("sha256").update(transcript).digest("hex"),
1666
+ ...evidence
1667
+ };
1668
+ delete compatibilityRecord.accepted;
1669
+ const compatibilityReportPath = await writeCompatibilityRecord(
1670
+ result.outputRoot,
1671
+ "sqlcl-warning-policy-validation.json",
1672
+ compatibilityRecord
1673
+ );
1674
+ const normalizedPayload = {
1675
+ ...payload,
1676
+ live_check_status: "pass",
1677
+ validation_status: "pass",
1678
+ warnings_as_errors: false,
1679
+ validation_sources: {
1680
+ ...(payload.validation_sources ?? {}),
1681
+ live_validator: {
1682
+ ...(payload.validation_sources?.live_validator ?? {}),
1683
+ status: "pass",
1684
+ policy: compatibilityRecord.policy,
1685
+ compatibility_report_path: compatibilityReportPath
1686
+ },
1687
+ ...(payload.validation_sources?.compiler_truth
1688
+ ? {
1689
+ compiler_truth: {
1690
+ ...payload.validation_sources.compiler_truth,
1691
+ gate_role: "diagnostic_after_live_pass"
1692
+ }
1693
+ }
1694
+ : {}),
1695
+ ...(payload.validation_sources?.vscode_problems
1696
+ ? {
1697
+ vscode_problems: {
1698
+ ...payload.validation_sources.vscode_problems,
1699
+ gate_role: "diagnostic_after_live_pass"
1700
+ }
1701
+ }
1702
+ : {})
1703
+ },
1704
+ blocking_reasons: [],
1705
+ diagnostic_reasons: diagnosticReasons,
1706
+ import_eligibility: "validate-only-passed-with-compile-warnings",
1707
+ compatibility_fallback: compatibilityRecord,
1708
+ notes: [
1709
+ ...(Array.isArray(payload.notes) ? payload.notes : []),
1710
+ "SQLcl returned an explicit Validation successful marker; compile warnings remain recorded as diagnostics."
1711
+ ]
1712
+ };
1713
+ return {
1714
+ ...result,
1715
+ ok: true,
1716
+ code: 0,
1717
+ stdout: `${JSON.stringify(normalizedPayload, null, 2)}\n`
1718
+ };
1719
+ }
1720
+
1721
+ function sameName(left, right) {
1722
+ return String(left ?? "").trim().toUpperCase() === String(right ?? "").trim().toUpperCase();
1723
+ }
1724
+
1725
+ export function validateUpdateExistingImportProof(payload, input, stagedAppPath) {
1726
+ if (payload.target_resolution_mode !== "update-existing" ||
1727
+ payload.target_resolution_status !== "resolved_existing_app" ||
1728
+ payload.direct_import_fallback_allowed !== true ||
1729
+ payload.canonical_mapping_status !== "resolved") {
1730
+ throw new Error("APEXlang compatibility import requires a resolved existing-app target");
1731
+ }
1732
+ const canonicalId = Number(payload.canonical_application_id);
1733
+ const candidateIds = Array.isArray(payload.candidate_ids)
1734
+ ? payload.candidate_ids.map(Number)
1735
+ : [];
1736
+ if (!Number.isInteger(canonicalId) || canonicalId <= 0 ||
1737
+ payload.candidate_count !== 1 ||
1738
+ candidateIds.length !== 1 ||
1739
+ candidateIds[0] !== canonicalId) {
1740
+ throw new Error("APEXlang compatibility import requires exactly one canonical application id");
1741
+ }
1742
+ if (!sameName(payload.canonical_application_alias, payload.source_application_alias) ||
1743
+ !payload.canonical_application_alias) {
1744
+ throw new Error("APEXlang compatibility import requires matching source and canonical aliases");
1745
+ }
1746
+ const sourceId = Number(payload.source_application_id);
1747
+ if (!Number.isInteger(sourceId) || sourceId <= 0 ||
1748
+ Number(payload.frozen_preflight_facts?.requested_application_id) !== sourceId ||
1749
+ !sameName(
1750
+ payload.frozen_preflight_facts?.requested_application_alias,
1751
+ payload.source_application_alias
1752
+ ) ||
1753
+ !sameName(
1754
+ payload.frozen_preflight_facts?.requested_application_alias,
1755
+ payload.canonical_application_alias
1756
+ )) {
1757
+ throw new Error("APEXlang compatibility import source identity does not match frozen preflight facts");
1758
+ }
1759
+ const workspaceId = String(payload.lookup_scope_workspaceid ?? "").trim();
1760
+ if (!/^\d+$/.test(workspaceId) || workspaceId !== String(payload.workspaceid ?? "").trim()) {
1761
+ throw new Error("APEXlang compatibility import requires one proven numeric workspace id");
1762
+ }
1763
+ if (!sameName(payload.lookup_scope_workspace_name, input.workspace_name) ||
1764
+ !sameName(payload.frozen_preflight_facts?.workspace_scope?.workspace_name, input.workspace_name) ||
1765
+ String(payload.frozen_preflight_facts?.workspace_scope?.workspace_id ?? "").trim() !== workspaceId) {
1766
+ throw new Error("APEXlang compatibility import workspace proof does not match workspace_name");
1767
+ }
1768
+ if (payload.db_connection_name !== input.db_connection_name ||
1769
+ payload.frozen_preflight_facts?.db_connection_name !== input.db_connection_name) {
1770
+ throw new Error("APEXlang compatibility import connection proof does not match db_connection_name");
1771
+ }
1772
+ if (payload.execution_mode_used !== "path" ||
1773
+ payload.frozen_preflight_facts?.execution_mode_selected !== "path" ||
1774
+ resolve(payload.final_app_path) !== resolve(stagedAppPath) ||
1775
+ resolve(payload.temp_app_path) !== resolve(stagedAppPath) ||
1776
+ resolve(payload.frozen_preflight_facts?.app_path ?? "") !== resolve(stagedAppPath)) {
1777
+ throw new Error("APEXlang compatibility import requires the proven staged PATH runtime app");
1778
+ }
1779
+ return {
1780
+ canonicalId,
1781
+ canonicalAlias: payload.canonical_application_alias,
1782
+ sourceId,
1783
+ sourceAlias: payload.source_application_alias,
1784
+ workspaceId,
1785
+ workspaceName: payload.lookup_scope_workspace_name
1786
+ };
1787
+ }
1788
+
1789
+ export function buildWarningCompatibleImportCommands({ appPath, workspaceId, canonicalId }) {
1790
+ const safeAppPath = validatePath(assertPlainText(String(appPath), "app_path"), "app_path");
1791
+ if (!/^\d+$/.test(String(workspaceId)) || !Number.isInteger(canonicalId) || canonicalId <= 0) {
1792
+ throw new Error("A proven workspace id and canonical application id are required for import");
1793
+ }
1794
+ return {
1795
+ validateCommand: `apex validate -input "${safeAppPath}" -workspaceid ${workspaceId}`,
1796
+ importCommand: `apex import -input "${safeAppPath}" -workspaceid ${workspaceId} -id ${canonicalId}`
1797
+ };
1798
+ }
1799
+
1800
+ export async function runWarningCompatibleImport(input, result, options, dependencies = {}) {
1801
+ input = normalizeApexlangInput(input);
1802
+ const payload = parseJsonRecord(result.stdout);
1803
+ if (!payload || !result.runtimeApp?.staged || !result.runtimeApp.appPath) return result;
1804
+ if (payload.transcript_path === undefined) return result;
1805
+
1806
+ const transcriptPath = await resolveOutputArtifact(
1807
+ result.outputRoot,
1808
+ payload.transcript_path,
1809
+ "runtime transcript_path"
1810
+ );
1811
+ const transcript = await readFile(transcriptPath, "utf8");
1812
+ const evidence = classifyWarningOnlyRuntimePayload(
1813
+ payload,
1814
+ transcript,
1815
+ "validate-and-import"
1816
+ );
1817
+ if (!evidence.accepted) return result;
1818
+
1819
+ const stagedProofPath = resolve(result.runtimeApp.appPath);
1820
+ const stagedAppPath = await realpath(stagedProofPath);
1821
+ const runtimeAppsPath = await realpath(resolve(result.outputRoot, "runtime-apps"));
1822
+ if (!isWithin(runtimeAppsPath, stagedAppPath)) {
1823
+ throw new Error("APEXlang compatibility import app must stay under APEXLANG_OUTPUT_ROOT/runtime-apps");
1824
+ }
1825
+ const proof = validateUpdateExistingImportProof(payload, input, stagedProofPath);
1826
+ const deploymentPath = resolve(stagedAppPath, "deployments/default.json");
1827
+ const deployment = parseJsonRecord(await readFile(deploymentPath, "utf8"));
1828
+ if (!deployment || !deployment.app || typeof deployment.app !== "object" || Array.isArray(deployment.app)) {
1829
+ throw new Error("Staged deployments/default.json must contain an app object before import");
1830
+ }
1831
+ const previousApplicationId = Number(deployment.app.id);
1832
+ if (!Number.isInteger(previousApplicationId) || previousApplicationId !== proof.sourceId) {
1833
+ throw new Error("Staged deployment application id does not match the frozen source identity");
1834
+ }
1835
+ const validatedAppDigest = requireAppDigest(result.appDigest, "validated appDigest");
1836
+ const currentAppDigest = await computeApexlangAppDigest(stagedAppPath);
1837
+ if (currentAppDigest !== validatedAppDigest) {
1838
+ throw new Error(
1839
+ "Staged APEXlang application changed after validation; revalidate before importing"
1840
+ );
1841
+ }
1842
+ deployment.app.id = proof.canonicalId;
1843
+ await writeFile(deploymentPath, `${JSON.stringify(deployment, null, 2)}\n`, "utf8");
1844
+
1845
+ const commands = buildWarningCompatibleImportCommands({
1846
+ appPath: stagedAppPath,
1847
+ workspaceId: proof.workspaceId,
1848
+ canonicalId: proof.canonicalId
1849
+ });
1850
+ const sessionRunner = dependencies.sessionRunner ?? executeSqlclValidationThenImport;
1851
+ const importResult = await sessionRunner("sql", ["-S", "-name", input.db_connection_name], {
1852
+ cwd: options.cwd,
1853
+ env: process.env,
1854
+ ...commands,
1855
+ maxBuffer: DEFAULT_MAX_BUFFER,
1856
+ signal: options.signal,
1857
+ timeoutMs: options.timeoutMs
1858
+ });
1859
+ const compatibilityDirectory = resolve(result.outputRoot, "logs/compat");
1860
+ await mkdir(compatibilityDirectory, { recursive: true });
1861
+ const importLogPath = resolve(compatibilityDirectory, "sqlcl-warning-policy-import.log");
1862
+ const fullSessionOutput = [importResult.stdout, importResult.stderr].filter(Boolean).join("\n");
1863
+ await writeFile(importLogPath, fullSessionOutput, "utf8");
1864
+ const validationOutput = [importResult.validationOutput, importResult.stderr]
1865
+ .filter(Boolean)
1866
+ .join("\n");
1867
+ const validationEvidence = classifyWarningCompatibleSqlclValidation(validationOutput);
1868
+ const hardFailureDetected = containsSqlclHardFailure(fullSessionOutput);
1869
+ const normalizedImportOutput = normalizeInteractiveSqlclOutput(
1870
+ importResult.importOutput ?? ""
1871
+ );
1872
+ const importedTargetMatches = [
1873
+ ...normalizedImportOutput.matchAll(SQLCL_IMPORT_TARGET_PATTERN)
1874
+ ];
1875
+ const importedTargets = importedTargetMatches.map((match) => ({
1876
+ applicationId: Number(match[1]),
1877
+ workspaceName: String(match[2]).trim()
1878
+ }));
1879
+ const importedIds = importedTargets.map((target) => target.applicationId);
1880
+ const importedWorkspaceNames = importedTargets.map((target) => target.workspaceName);
1881
+ const importedTarget = importedTargets.length === 1 ? importedTargets[0] : null;
1882
+ const importedId = importedTarget?.applicationId ?? null;
1883
+ const importSuccessMatches = [
1884
+ ...normalizedImportOutput.matchAll(SQLCL_IMPORT_SUCCESS_PATTERN)
1885
+ ];
1886
+ const importSuccessCount = importSuccessMatches.length;
1887
+ const targetReportedBeforeSuccess = Boolean(
1888
+ importedTargetMatches.length === 1 &&
1889
+ importSuccessMatches.length === 1 &&
1890
+ importedTargetMatches[0].index < importSuccessMatches[0].index
1891
+ );
1892
+ const importSuccessIsTerminal = Boolean(
1893
+ importSuccessMatches.length === 1 &&
1894
+ normalizedImportOutput.slice(
1895
+ importSuccessMatches[0].index + importSuccessMatches[0][0].length
1896
+ ).trim() === ""
1897
+ );
1898
+ if (!importResult.ok || importResult.code !== 0 ||
1899
+ importResult.ptyBacked !== true ||
1900
+ importResult.ptyProcessGroupReady !== true ||
1901
+ importResult.sessionReady !== true ||
1902
+ importResult.validationSent !== true ||
1903
+ importResult.validationAccepted !== true ||
1904
+ importResult.importSent !== true ||
1905
+ importResult.importCompleted !== true ||
1906
+ importResult.orderedMergedOutput !== true ||
1907
+ !validationEvidence.accepted ||
1908
+ hardFailureDetected ||
1909
+ importSuccessCount !== 1 ||
1910
+ !targetReportedBeforeSuccess ||
1911
+ !importSuccessIsTerminal ||
1912
+ importedTargets.length !== 1 ||
1913
+ importedId !== proof.canonicalId ||
1914
+ !sameName(importedTarget?.workspaceName, proof.workspaceName)) {
1915
+ await writeCompatibilityRecord(
1916
+ result.outputRoot,
1917
+ "sqlcl-warning-policy-import-failure.json",
1918
+ {
1919
+ status: "fail",
1920
+ timestamp: new Date().toISOString(),
1921
+ canonical_application_id: proof.canonicalId,
1922
+ workspace_id: proof.workspaceId,
1923
+ pty_backed: importResult.ptyBacked === true,
1924
+ pty_process_group_ready: importResult.ptyProcessGroupReady === true,
1925
+ session_ready: importResult.sessionReady === true,
1926
+ validation_sent: importResult.validationSent === true,
1927
+ validation_accepted: importResult.validationAccepted === true,
1928
+ import_sent: importResult.importSent === true,
1929
+ import_completed: importResult.importCompleted === true,
1930
+ ordered_merged_output: importResult.orderedMergedOutput === true,
1931
+ imported_application_ids: importedIds,
1932
+ imported_workspace_names: importedWorkspaceNames,
1933
+ import_success_count: importSuccessCount,
1934
+ target_reported_before_success: targetReportedBeforeSuccess,
1935
+ import_success_is_terminal: importSuccessIsTerminal,
1936
+ hard_failure_detected: hardFailureDetected,
1937
+ validation_evidence: validationEvidence,
1938
+ import_log_path: importLogPath,
1939
+ import_log_sha256: createHash("sha256").update(fullSessionOutput).digest("hex")
1940
+ }
1941
+ );
1942
+ throw new Error("APEXlang compatibility validate-and-import did not return proven SQLcl success");
1943
+ }
1944
+
1945
+ const compatibilityRecord = {
1946
+ status: "pass",
1947
+ policy: "sqlcl_explicit_validation_success_with_compile_warnings",
1948
+ timestamp: new Date().toISOString(),
1949
+ target_resolution_status: payload.target_resolution_status,
1950
+ canonical_application_id: proof.canonicalId,
1951
+ canonical_application_alias: proof.canonicalAlias,
1952
+ workspace_id: proof.workspaceId,
1953
+ workspace_name: proof.workspaceName,
1954
+ db_connection_name: input.db_connection_name,
1955
+ staged_app_path: stagedAppPath,
1956
+ validated_app_digest: validatedAppDigest,
1957
+ staged_deployment_previous_application_id: Number.isInteger(previousApplicationId)
1958
+ ? previousApplicationId
1959
+ : null,
1960
+ staged_deployment_reconciled: previousApplicationId !== proof.canonicalId,
1961
+ imported_application_id: importedId,
1962
+ imported_workspace_name: importedTarget.workspaceName,
1963
+ import_success_count: importSuccessCount,
1964
+ target_reported_before_success: true,
1965
+ import_success_is_terminal: true,
1966
+ sqlcl_entrypoint: "sql -S -name <saved-alias>",
1967
+ pty_backed_sqlcl_session: true,
1968
+ pty_process_group_ready: true,
1969
+ session_ready_marker: true,
1970
+ one_session_validate_and_import: true,
1971
+ validation_gated_before_import_send: true,
1972
+ ordered_merged_output: true,
1973
+ source_runtime_report_path: payload.report_path,
1974
+ source_runtime_transcript_path: transcriptPath,
1975
+ source_runtime_transcript_sha256: createHash("sha256").update(transcript).digest("hex"),
1976
+ import_log_path: importLogPath,
1977
+ import_log_sha256: createHash("sha256").update(fullSessionOutput).digest("hex"),
1978
+ validation_evidence: validationEvidence,
1979
+ ...evidence
1980
+ };
1981
+ delete compatibilityRecord.accepted;
1982
+ const compatibilityReportPath = await writeCompatibilityRecord(
1983
+ result.outputRoot,
1984
+ "sqlcl-warning-policy-import.json",
1985
+ compatibilityRecord
1986
+ );
1987
+ const normalizedPayload = {
1988
+ ...payload,
1989
+ warnings_as_errors: false,
1990
+ validate_status: "pass",
1991
+ live_check_status: "pass",
1992
+ live_check_token: "APEXLANG_LIVE_CHECK_OK",
1993
+ final_check_status: "pass",
1994
+ import_status: "pass",
1995
+ runtime_gate_status: "pass",
1996
+ failure_class: "",
1997
+ blocking_reason: "",
1998
+ blocking_findings_count: 0,
1999
+ repair_loop_required: false,
2000
+ validation_feedback_status: "diagnostics-recorded",
2001
+ review_status: "diagnostics_recorded",
2002
+ recommended_next_action: "Import completed; review the recorded compile-warning diagnostics.",
2003
+ direct_import_bypass_forbidden: true,
2004
+ import_mode_used: "direct",
2005
+ import_lane_fallback_used: true,
2006
+ import_lane_fallback_reason: compatibilityRecord.policy,
2007
+ session_entrypoint_used: "compat_sql_name_alias",
2008
+ canonical_mapping_reconciled_source: previousApplicationId !== proof.canonicalId,
2009
+ phase_reports: [
2010
+ ...(Array.isArray(payload.phase_reports)
2011
+ ? payload.phase_reports.map((phase) => phase?.phase === "live_validate"
2012
+ ? {
2013
+ ...phase,
2014
+ original_status: phase.status,
2015
+ original_failure_class: phase.failure_class,
2016
+ status: "pass",
2017
+ failure_class: "",
2018
+ next_safe_action: "Continue to import.",
2019
+ compatibility_policy: compatibilityRecord.policy
2020
+ }
2021
+ : phase)
2022
+ : []),
2023
+ {
2024
+ phase: "import",
2025
+ status: "pass",
2026
+ failure_class: "",
2027
+ next_safe_action: "Import completed.",
2028
+ compatibility_policy: compatibilityRecord.policy
2029
+ }
2030
+ ],
2031
+ compatibility_fallback: {
2032
+ ...compatibilityRecord,
2033
+ report_path: compatibilityReportPath
2034
+ },
2035
+ notes: [
2036
+ ...(Array.isArray(payload.notes) ? payload.notes : []),
2037
+ "Imported after SQLcl explicitly validated the same staged app in the same saved-alias session; compile warnings remain recorded as diagnostics."
2038
+ ]
2039
+ };
2040
+ return {
2041
+ ...result,
2042
+ ok: true,
2043
+ code: 0,
2044
+ stdout: `${JSON.stringify(normalizedPayload, null, 2)}\n`,
2045
+ stderr: ""
2046
+ };
2047
+ }
2048
+
691
2049
  async function enforceMaterializationGate(input, result, options) {
692
2050
  if (!result.ok || input.action !== "new_app_materialize") {
693
2051
  return result;
@@ -749,11 +2107,8 @@ async function enforceMaterializationGate(input, result, options) {
749
2107
  return result;
750
2108
  }
751
2109
 
752
- async function runCommand(input, command, options) {
753
- input = { ...input };
754
- for (const key of ["app_path", "db_connection_name", "workspace_name"]) {
755
- if (typeof input[key] === "string") input[key] = input[key].trim();
756
- }
2110
+ async function runCommand(input, command, options, runOptions = {}) {
2111
+ input = normalizeApexlangInput(input);
757
2112
  if (typeof input.app_path === "string" && input.app_path.trim()) {
758
2113
  const appPath = await assertProjectAppPath(input.app_path.trim(), options.cwd);
759
2114
  await assertSymlinkFreeTree(appPath, {
@@ -763,7 +2118,21 @@ async function runCommand(input, command, options) {
763
2118
  if (input.action === "workspace_probe" || command.prelude.length > 0) {
764
2119
  await assertWorkspaceProbeCannotFollowSymlinks(options.cwd);
765
2120
  }
766
- await assertDeploymentWorkspace(input, options.cwd);
2121
+ const runtimeApp = await prepareRuntimeApp(input, options.cwd, options.outputRoot, {
2122
+ forceStage: runOptions.forceRuntimeStage === true
2123
+ });
2124
+ const preRunAppDigest = runtimeApp.appPath && runtimeActionUsesDeployment(input)
2125
+ ? await computeApexlangAppDigest(runtimeApp.appPath)
2126
+ : undefined;
2127
+ if (runOptions.expectedAppDigest !== undefined) {
2128
+ const expectedAppDigest = requireAppDigest(runOptions.expectedAppDigest);
2129
+ if (!preRunAppDigest || preRunAppDigest !== expectedAppDigest) {
2130
+ throw new Error(
2131
+ "APEXlang application changed after the approved live check; revalidate before importing"
2132
+ );
2133
+ }
2134
+ }
2135
+ command = runtimeApp.staged ? commandWithAppPath(command, runtimeApp.appPath) : command;
767
2136
  const preludeResults = [];
768
2137
  for (const preludeCommand of command.prelude) {
769
2138
  const processResult = await invoke(preludeCommand, options);
@@ -775,36 +2144,67 @@ async function runCommand(input, command, options) {
775
2144
  action: input.action,
776
2145
  command,
777
2146
  outputRoot: options.outputRoot,
778
- preludeResults
2147
+ preludeResults,
2148
+ runtimeApp,
2149
+ appDigest: preRunAppDigest
779
2150
  };
780
2151
  }
781
2152
  }
782
2153
 
2154
+ await clearStaleRuntimeEvidence(command, options.outputRoot);
783
2155
  const result = await invoke(command, options);
2156
+ const postRunAppDigest = runtimeApp.appPath && runtimeActionUsesDeployment(input)
2157
+ ? await computeApexlangAppDigest(runtimeApp.appPath)
2158
+ : undefined;
2159
+ if (preRunAppDigest && postRunAppDigest !== preRunAppDigest) {
2160
+ throw new Error(
2161
+ "APEXlang runtime changed the staged application during validation; review and revalidate before importing"
2162
+ );
2163
+ }
784
2164
  return {
785
2165
  ...result,
786
2166
  action: input.action,
787
2167
  command,
788
2168
  outputRoot: options.outputRoot,
789
- preludeResults
2169
+ preludeResults,
2170
+ runtimeApp,
2171
+ appDigest: postRunAppDigest ?? preRunAppDigest
790
2172
  };
791
2173
  }
792
2174
 
793
- export function runApexlang(input, options) {
794
- return runCommand(input, buildApexlangCommand(input), options);
2175
+ export async function runApexlang(input, options) {
2176
+ const normalizedInput = normalizeApexlangInput(input);
2177
+ const result = await runCommand(
2178
+ normalizedInput,
2179
+ buildApexlangCommand(normalizedInput),
2180
+ options
2181
+ );
2182
+ return normalizeWarningOnlyValidation(normalizedInput, result);
795
2183
  }
796
2184
 
797
- export function runApexlangImport(input, options, importOptions = {}) {
798
- return runCommand(input, buildApexlangImportCommand(input, importOptions), options);
2185
+ export async function runApexlangImport(input, options, importOptions = {}) {
2186
+ const normalizedInput = normalizeApexlangInput(input);
2187
+ const expectedAppDigest = requireAppDigest(importOptions.expectedAppDigest);
2188
+ const result = await runCommand(
2189
+ normalizedInput,
2190
+ buildApexlangImportCommand(normalizedInput, importOptions),
2191
+ options,
2192
+ { forceRuntimeStage: true, expectedAppDigest }
2193
+ );
2194
+ if (result.ok || importOptions.targetResolutionMode === "create-new") return result;
2195
+ return runWarningCompatibleImport(normalizedInput, result, options);
799
2196
  }
800
2197
 
801
- export function runApexlangCreateNewProof(input, options) {
2198
+ export function runApexlangCreateNewProof(input, options, proofOptions = {}) {
2199
+ const normalizedInput = normalizeApexlangInput(input);
2200
+ const expectedAppDigest = requireAppDigest(proofOptions.expectedAppDigest);
802
2201
  return runCommand(
803
- input,
804
- buildApexlangImportCommand(input, {
2202
+ normalizedInput,
2203
+ buildApexlangImportCommand(normalizedInput, {
805
2204
  targetResolutionMode: "create-new",
806
2205
  createNewConfirmed: false
807
2206
  }),
808
- options
2207
+ options,
2208
+ { forceRuntimeStage: true, expectedAppDigest }
809
2209
  );
810
2210
  }