#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")" # LinuxCNC source basis: switchkins/remap table validation derives M428/M429/M430 # cases from LinuxCNC configs/sim INI/HAL files, remap_subs/*.ngc # # assignments, and src/emc/kinematics switchkins sources. linuxcnc_root=${LINUXCNC_ROOT:-../linuxcnc} manifest=${1:-linuxcnc-kinematics-source-files.txt} usage() { echo "usage: $0 [manifest]" >&2 } if [[ "${manifest:-}" == "--help" || "${manifest:-}" == "-h" ]]; then usage exit 0 fi if [[ "${manifest:-}" == --* ]]; then usage echo "unknown switchkins remap table checker option: $manifest" >&2 exit 1 fi if [[ -n "${2:-}" ]]; then usage echo "too many switchkins remap table checker arguments" >&2 exit 1 fi if [[ ! -d "$linuxcnc_root" ]]; then echo "missing LinuxCNC root: $linuxcnc_root" >&2 exit 1 fi if [[ ! -f "$manifest" ]]; then echo "missing kinematics manifest: $manifest" >&2 exit 1 fi LINUXCNC_ROOT="$linuxcnc_root" ./check-linuxcnc-inputs-cached.sh "$manifest" --require-kinematics-complete validate_manifest_switchkins_complete() { local manifest_file=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" <<'PY' import os import re import sys linuxcnc_root, manifest_file = sys.argv[1:] sim_root = os.path.join(linuxcnc_root, "configs", "sim") remap_ini_re = re.compile(r"REMAP\s*=\s*M(428|429|430)(?:\D|$)", re.I) switchkins_remap_names = {"428remap.ngc", "429remap.ngc", "430remap.ngc"} manifest_config_paths = set() manifest_remap_paths = set() with open(manifest_file, encoding="utf-8") as manifest: for line in manifest: parts = line.rstrip("\n").split(":", 2) if len(parts) < 2: continue group, source_path = parts[:2] if group == "config" and source_path.endswith(".ini"): with open(os.path.join(linuxcnc_root, source_path), encoding="utf-8") as handle: if remap_ini_re.search(handle.read()): manifest_config_paths.add(source_path) elif group == "remap": manifest_remap_paths.add(source_path) linuxcnc_config_paths = set() linuxcnc_remap_paths = set() for current_root, _dirs, files in os.walk(sim_root): for filename in files: source = os.path.join(current_root, filename) source_path = os.path.relpath(source, linuxcnc_root) if filename.endswith(".ini"): with open(source, encoding="utf-8", errors="ignore") as handle: if remap_ini_re.search(handle.read()): linuxcnc_config_paths.add(source_path) elif filename in switchkins_remap_names: linuxcnc_remap_paths.add(source_path) missing_configs = sorted(linuxcnc_config_paths - manifest_config_paths) extra_configs = sorted(manifest_config_paths - linuxcnc_config_paths) missing_remaps = sorted(linuxcnc_remap_paths - manifest_remap_paths) extra_remaps = sorted(manifest_remap_paths - linuxcnc_remap_paths) for source_path in missing_configs: print(f"missing LinuxCNC switchkins REMAP INI in manifest: {source_path}", file=sys.stderr) for source_path in extra_configs: print(f"unexpected LinuxCNC switchkins REMAP INI in manifest: {source_path}", file=sys.stderr) for source_path in missing_remaps: print(f"missing LinuxCNC switchkins remap source in manifest: {source_path}", file=sys.stderr) for source_path in extra_remaps: print(f"unexpected LinuxCNC switchkins remap source in manifest: {source_path}", file=sys.stderr) if missing_configs or extra_configs or missing_remaps or extra_remaps: raise SystemExit(1) PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile] = process.argv.slice(2); const simRoot = path.join(linuxcncRoot, "configs", "sim"); const remapIniRe = /REMAP\s*=\s*M(428|429|430)(?:\D|$)/i; const switchkinsRemapNames = new Set(["428remap.ngc", "429remap.ngc", "430remap.ngc"]); const manifestConfigPaths = new Set(); const manifestRemapPaths = new Set(); for (const line of fs.readFileSync(manifestFile, "utf8").split(/\r?\n/)) { const parts = line.split(":", 3); if (parts.length < 2) { continue; } const [group, sourcePath] = parts; if (group === "config" && sourcePath.endsWith(".ini")) { const text = fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8"); if (remapIniRe.test(text)) { manifestConfigPaths.add(sourcePath); } } else if (group === "remap") { manifestRemapPaths.add(sourcePath); } } function walkFiles(root) { const files = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { const fullPath = path.join(root, entry.name); if (entry.isDirectory()) { files.push(...walkFiles(fullPath)); } else if (entry.isFile()) { files.push(fullPath); } } return files; } const linuxcncConfigPaths = new Set(); const linuxcncRemapPaths = new Set(); for (const source of walkFiles(simRoot)) { const sourcePath = path.relative(linuxcncRoot, source); const basename = path.basename(source); if (basename.endsWith(".ini")) { const text = fs.readFileSync(source, "utf8"); if (remapIniRe.test(text)) { linuxcncConfigPaths.add(sourcePath); } } else if (switchkinsRemapNames.has(basename)) { linuxcncRemapPaths.add(sourcePath); } } const missingConfigs = [...linuxcncConfigPaths].filter((sourcePath) => !manifestConfigPaths.has(sourcePath)).sort(); const extraConfigs = [...manifestConfigPaths].filter((sourcePath) => !linuxcncConfigPaths.has(sourcePath)).sort(); const missingRemaps = [...linuxcncRemapPaths].filter((sourcePath) => !manifestRemapPaths.has(sourcePath)).sort(); const extraRemaps = [...manifestRemapPaths].filter((sourcePath) => !linuxcncRemapPaths.has(sourcePath)).sort(); for (const sourcePath of missingConfigs) { console.error(`missing LinuxCNC switchkins REMAP INI in manifest: ${sourcePath}`); } for (const sourcePath of extraConfigs) { console.error(`unexpected LinuxCNC switchkins REMAP INI in manifest: ${sourcePath}`); } for (const sourcePath of missingRemaps) { console.error(`missing LinuxCNC switchkins remap source in manifest: ${sourcePath}`); } for (const sourcePath of extraRemaps) { console.error(`unexpected LinuxCNC switchkins remap source in manifest: ${sourcePath}`); } if (missingConfigs.length || extraConfigs.length || missingRemaps.length || extraRemaps.length) { process.exit(1); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap manifest completeness validation" >&2 exit 1 fi } validate_manifest_switchkins_complete "$manifest" validate_manifest_remap_kinstypes() { local manifest_file=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" <<'PY' import os import re import sys linuxcnc_root, manifest_file = sys.argv[1:] remap_re = re.compile(r'^\s*#\s*=\s*(-?\d+)\b') seen = {} with open(manifest_file, encoding="utf-8") as manifest: for line_number, line in enumerate(manifest, 1): parts = line.rstrip("\n").split(":", 2) if len(parts) < 2 or parts[0] != "remap": continue path = parts[1] basename = os.path.basename(path) if basename not in {"428remap.ngc", "429remap.ngc", "430remap.ngc"}: raise SystemExit(f"unexpected switchkins remap source at {manifest_file}:{line_number}: {path}") if path in seen: raise SystemExit(f"duplicate switchkins remap source at {manifest_file}:{line_number}: {path}") seen[path] = line_number source = os.path.join(linuxcnc_root, path) with open(source, encoding="utf-8") as handle: kinstypes = [int(match.group(1)) for match in map(remap_re.match, handle) if match] if len(kinstypes) != 1: raise SystemExit(f"expected exactly one # assignment in {path}, found {len(kinstypes)}") if kinstypes[0] < 0: raise SystemExit(f"invalid negative # assignment in {path}: {kinstypes[0]}") if not seen: raise SystemExit(f"no switchkins remap sources listed in {manifest_file}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile] = process.argv.slice(2); const remapRe = /^\s*#\s*=\s*(-?\d+)\b/; const seen = new Map(); for (const [index, line] of fs.readFileSync(manifestFile, "utf8").split(/\r?\n/).entries()) { const parts = line.split(":", 3); if (parts.length < 2 || parts[0] !== "remap") { continue; } const sourcePath = parts[1]; const basename = path.basename(sourcePath); if (!["428remap.ngc", "429remap.ngc", "430remap.ngc"].includes(basename)) { throw new Error(`unexpected switchkins remap source at ${manifestFile}:${index + 1}: ${sourcePath}`); } if (seen.has(sourcePath)) { throw new Error(`duplicate switchkins remap source at ${manifestFile}:${index + 1}: ${sourcePath}`); } seen.set(sourcePath, index + 1); const kinstypes = fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8") .split(/\r?\n/) .map((sourceLine) => remapRe.exec(sourceLine)) .filter(Boolean) .map((match) => Number(match[1])); if (kinstypes.length !== 1) { throw new Error(`expected exactly one # assignment in ${sourcePath}, found ${kinstypes.length}`); } if (kinstypes[0] < 0) { throw new Error(`invalid negative # assignment in ${sourcePath}: ${kinstypes[0]}`); } } if (seen.size === 0) { throw new Error(`no switchkins remap sources listed in ${manifestFile}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap manifest validation" >&2 exit 1 fi } validate_manifest_remap_kinstypes "$manifest" validate_manifest_config_remaps() { local manifest_file=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" <<'PY' import os import re import sys linuxcnc_root, manifest_file = sys.argv[1:] remap_re = re.compile(r'REMAP\s*=\s*M(428|429|430)(?:\D|$)', re.I) seen = {} with open(manifest_file, encoding="utf-8") as manifest: for line_number, line in enumerate(manifest, 1): parts = line.rstrip("\n").split(":", 2) if len(parts) < 2 or parts[0] != "config" or not parts[1].endswith(".ini"): continue source_path = parts[1] source = os.path.join(linuxcnc_root, source_path) with open(source, encoding="utf-8") as handle: mcodes = {int(match.group(1)) for match in remap_re.finditer(handle.read())} if not mcodes: continue seen[source_path] = mcodes missing = {428, 429} - mcodes if missing: raise SystemExit(f"switchkins config {source_path} is missing required M-code remap(s): {sorted(missing)}") if not seen: raise SystemExit(f"no switchkins REMAP config sources listed in {manifest_file}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile] = process.argv.slice(2); const remapRe = /REMAP\s*=\s*M(428|429|430)(?:\D|$)/ig; const seen = new Map(); for (const line of fs.readFileSync(manifestFile, "utf8").split(/\r?\n/)) { const parts = line.split(":", 3); if (parts.length < 2 || parts[0] !== "config" || !parts[1].endsWith(".ini")) { continue; } const sourcePath = parts[1]; const text = fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8"); const mcodes = new Set(); for (const match of text.matchAll(remapRe)) { mcodes.add(Number(match[1])); } if (mcodes.size === 0) { continue; } seen.set(sourcePath, mcodes); const missing = [428, 429].filter((mcode) => !mcodes.has(mcode)); if (missing.length > 0) { throw new Error(`switchkins config ${sourcePath} is missing required M-code remap(s): ${missing.join(",")}`); } } if (seen.size === 0) { throw new Error(`no switchkins REMAP config sources listed in ${manifestFile}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap config validation" >&2 exit 1 fi } validate_manifest_config_remaps "$manifest" validate_generator_cli_contract() { local manifest_file=$1 LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --help >/dev/null 2>&1 if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --unknown "$manifest_file" >/dev/null 2>&1; then echo "switchkins remap generator accepted unknown option" >&2 exit 1 fi if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --all-output-dir >/dev/null 2>&1; then echo "switchkins remap generator accepted --all-output-dir without directory" >&2 exit 1 fi if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh "$manifest_file" extra >/dev/null 2>&1; then echo "switchkins remap generator accepted too many arguments" >&2 exit 1 fi ( local cli_dir local cli_manifest local cli_manifest_with_config_ngc local cli_manifest_with_config_tbl local cli_manifest_with_duplicate_ini local cli_manifest_with_duplicate_remap local cli_manifest_with_duplicate_tooldata local cli_manifest_with_unused_hal local cli_manifest_with_unused_postgui local cli_manifest_with_unused_remap local cli_manifest_with_unused_tooldata local cli_manifest_without_halfile_loadusr_asset local cli_melfa_manifest local cli_melfa_without_halcmd_loadusr_asset local cli_trsrn_manifest local cli_trsrn_without_g531_asset local cli_trsrn_without_g69_asset local cli_trsrn_without_halcmd_asset local cli_trsrn_without_halcmd_import_asset local cli_trsrn_without_on_abort_asset local cli_trsrn_without_python_import_asset local cli_trsrn_without_python_toplevel_asset local cli_manifest_without_ini local cli_manifest_without_hal local cli_manifest_without_postgui local cli_manifest_without_remap local cli_manifest_without_tooldata local cli_output_root_log local cli_output_whitespace_log cli_dir=$(mktemp -d "${TMPDIR:-/tmp}/linuxcnc_switchkins_remap_cli.XXXXXX") trap 'rm -rf "$cli_dir"' EXIT cli_manifest="$cli_dir/bridgemill-manifest.txt" cli_manifest_with_config_ngc="$cli_dir/bridgemill-manifest-with-config-ngc.txt" cli_manifest_with_config_tbl="$cli_dir/bridgemill-manifest-with-config-tbl.txt" cli_manifest_with_duplicate_ini="$cli_dir/bridgemill-manifest-with-duplicate-ini.txt" cli_manifest_with_duplicate_remap="$cli_dir/bridgemill-manifest-with-duplicate-remap.txt" cli_manifest_with_duplicate_tooldata="$cli_dir/bridgemill-manifest-with-duplicate-tooldata.txt" cli_manifest_with_unused_hal="$cli_dir/bridgemill-manifest-with-unused-hal.txt" cli_manifest_with_unused_postgui="$cli_dir/bridgemill-manifest-with-unused-postgui.txt" cli_manifest_with_unused_remap="$cli_dir/bridgemill-manifest-with-unused-remap.txt" cli_manifest_with_unused_tooldata="$cli_dir/bridgemill-manifest-with-unused-tooldata.txt" cli_manifest_without_halfile_loadusr_asset="$cli_dir/bridgemill-manifest-without-halfile-loadusr-asset.txt" cli_melfa_manifest="$cli_dir/melfa-manifest.txt" cli_melfa_without_halcmd_loadusr_asset="$cli_dir/melfa-manifest-without-halcmd-loadusr-asset.txt" cli_trsrn_manifest="$cli_dir/trsrn-manifest.txt" cli_trsrn_without_g531_asset="$cli_dir/trsrn-manifest-without-g531-asset.txt" cli_trsrn_without_g69_asset="$cli_dir/trsrn-manifest-without-g69-asset.txt" cli_trsrn_without_halcmd_asset="$cli_dir/trsrn-manifest-without-halcmd-asset.txt" cli_trsrn_without_halcmd_import_asset="$cli_dir/trsrn-manifest-without-halcmd-import-asset.txt" cli_trsrn_without_on_abort_asset="$cli_dir/trsrn-manifest-without-on-abort-asset.txt" cli_trsrn_without_python_import_asset="$cli_dir/trsrn-manifest-without-python-import-asset.txt" cli_trsrn_without_python_toplevel_asset="$cli_dir/trsrn-manifest-without-python-toplevel-asset.txt" cli_manifest_without_ini="$cli_dir/bridgemill-manifest-without-ini.txt" cli_manifest_without_hal="$cli_dir/bridgemill-manifest-without-hal.txt" cli_manifest_without_postgui="$cli_dir/bridgemill-manifest-without-postgui.txt" cli_manifest_without_remap="$cli_dir/bridgemill-manifest-without-remap.txt" cli_manifest_without_tooldata="$cli_dir/bridgemill-manifest-without-tooldata.txt" cli_output_root_log="$cli_dir/output-root.log" cli_output_whitespace_log="$cli_dir/output-whitespace.log" if ! awk -F: -v manifest="$manifest_file" ' function require(key) { expected[++expected_count] = key expected_keys[key] = 1 } BEGIN { require("config:configs/sim/axis/vismach/5axis/bridgemill/5axis.ini") require("config:configs/sim/axis/vismach/5axis/bridgemill/5axisgui.hal") require("config:configs/sim/axis/vismach/5axis/bridgemill/5axis_postgui.hal") require("remap:configs/sim/axis/vismach/5axis/bridgemill/remap_subs/428remap.ngc") require("remap:configs/sim/axis/vismach/5axis/bridgemill/remap_subs/429remap.ngc") require("remap:configs/sim/axis/vismach/5axis/bridgemill/remap_subs/430remap.ngc") require("tooldata:configs/sim/axis/vismach/5axis/bridgemill/5axis.tbl") require("asset:src/hal/user_comps/vismach/5axisgui.py") } { key = $1 ":" $2 if (key in expected_keys) { if (key in manifest_line) { print "duplicate switchkins remap generator CLI contract source in " manifest ": " key > "/dev/stderr" duplicate = 1 } manifest_line[key] = $0 } } END { missing = 0 for (expected_index = 1; expected_index <= expected_count; expected_index++) { key = expected[expected_index] if (!(key in manifest_line)) { print "switchkins remap generator CLI contract source is not listed in " manifest ": " key > "/dev/stderr" missing = 1 continue } print manifest_line[key] } exit missing || duplicate } ' "$manifest_file" >"$cli_manifest"; then exit 1 fi cp "$cli_manifest" "$cli_manifest_with_config_ngc" grep -F \ 'remap:configs/sim/axis/vismach/5axis/bridgemill/remap_subs/428remap.ngc:' \ "$cli_manifest" | sed 's/^remap:/config:/' >>"$cli_manifest_with_config_ngc" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_config_ngc" >/dev/null 2>&1; then echo "switchkins remap generator accepted LinuxCNC remap source listed as config" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_config_tbl" grep -F \ 'tooldata:configs/sim/axis/vismach/5axis/bridgemill/5axis.tbl:' \ "$cli_manifest" | sed 's/^tooldata:/config:/' >>"$cli_manifest_with_config_tbl" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_config_tbl" >/dev/null 2>&1; then echo "switchkins remap generator accepted LinuxCNC TOOL_TABLE source listed as config" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_duplicate_ini" grep -F \ 'config:configs/sim/axis/vismach/5axis/bridgemill/5axis.ini:' \ "$cli_manifest" >>"$cli_manifest_with_duplicate_ini" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_duplicate_ini" >/dev/null 2>&1; then echo "switchkins remap generator accepted duplicate LinuxCNC INI config source" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_duplicate_remap" grep -F \ 'remap:configs/sim/axis/vismach/5axis/bridgemill/remap_subs/428remap.ngc:' \ "$cli_manifest" >>"$cli_manifest_with_duplicate_remap" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_duplicate_remap" >/dev/null 2>&1; then echo "switchkins remap generator accepted duplicate LinuxCNC M428 remap source" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_duplicate_tooldata" grep -F \ 'tooldata:configs/sim/axis/vismach/5axis/bridgemill/5axis.tbl:' \ "$cli_manifest" >>"$cli_manifest_with_duplicate_tooldata" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_duplicate_tooldata" >/dev/null 2>&1; then echo "switchkins remap generator accepted duplicate LinuxCNC TOOL_TABLE source" >&2 exit 1 fi grep -Fv \ 'config:configs/sim/axis/vismach/5axis/bridgemill/5axis.ini:' \ "$cli_manifest" >"$cli_manifest_without_ini" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_without_ini" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC INI config source" >&2 exit 1 fi grep -Fv \ 'config:configs/sim/axis/vismach/5axis/bridgemill/5axisgui.hal:' \ "$cli_manifest" >"$cli_manifest_without_hal" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_without_hal" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC HALFILE source" >&2 exit 1 fi grep -Fv \ 'config:configs/sim/axis/vismach/5axis/bridgemill/5axis_postgui.hal:' \ "$cli_manifest" >"$cli_manifest_without_postgui" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_without_postgui" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC POSTGUI_HALFILE source" >&2 exit 1 fi grep -Fv \ 'remap:configs/sim/axis/vismach/5axis/bridgemill/remap_subs/430remap.ngc:' \ "$cli_manifest" >"$cli_manifest_without_remap" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_without_remap" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC M430 remap source" >&2 exit 1 fi grep -Fv \ 'tooldata:configs/sim/axis/vismach/5axis/bridgemill/5axis.tbl:' \ "$cli_manifest" >"$cli_manifest_without_tooldata" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_without_tooldata" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC TOOL_TABLE source" >&2 exit 1 fi grep -Fv \ 'asset:src/hal/user_comps/vismach/5axisgui.py:' \ "$cli_manifest" >"$cli_manifest_without_halfile_loadusr_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_without_halfile_loadusr_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC HALFILE loadusr asset source" >&2 exit 1 fi if ! awk -F: -v manifest="$manifest_file" ' function require(key) { expected[++expected_count] = key expected_keys[key] = 1 } BEGIN { require("config:configs/sim/axis/vismach/melfa-sim/melfa.ini") require("config:configs/sim/axis/vismach/melfa-sim/melfa_dh.hal") require("config:configs/sim/axis/vismach/melfa-sim/melfa-postgui.hal") require("remap:configs/sim/axis/vismach/melfa-sim/remap_subs/428remap.ngc") require("remap:configs/sim/axis/vismach/melfa-sim/remap_subs/429remap.ngc") require("remap:configs/sim/axis/vismach/melfa-sim/remap_subs/430remap.ngc") require("tooldata:configs/sim/axis/vismach/melfa-sim/melfa.tbl") require("asset:src/hal/user_comps/vismach/melfagui.py") } { key = $1 ":" $2 if (key in expected_keys) { if (key in manifest_line) { print "duplicate melfa switchkins source in " manifest ": " key > "/dev/stderr" duplicate = 1 } manifest_line[key] = $0 } } END { missing = 0 for (expected_index = 1; expected_index <= expected_count; expected_index++) { key = expected[expected_index] if (!(key in manifest_line)) { print "melfa switchkins source is not listed in " manifest ": " key > "/dev/stderr" missing = 1 continue } print manifest_line[key] } exit missing || duplicate } ' "$manifest_file" >"$cli_melfa_manifest"; then exit 1 fi grep -Fv \ 'asset:src/hal/user_comps/vismach/melfagui.py:' \ "$cli_melfa_manifest" >"$cli_melfa_without_halcmd_loadusr_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_melfa_without_halcmd_loadusr_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC HALCMD loadusr command asset source" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_unused_hal" grep -F \ 'config:configs/sim/axis/vismach/melfa-sim/melfa_dh.hal:' \ "$manifest_file" >>"$cli_manifest_with_unused_hal" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_unused_hal" >/dev/null 2>&1; then echo "switchkins remap generator accepted unused LinuxCNC HALFILE source" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_unused_postgui" grep -F \ 'config:configs/sim/axis/vismach/melfa-sim/melfa-postgui.hal:' \ "$manifest_file" >>"$cli_manifest_with_unused_postgui" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_unused_postgui" >/dev/null 2>&1; then echo "switchkins remap generator accepted unused LinuxCNC POSTGUI_HALFILE source" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_unused_remap" grep -F \ 'remap:configs/sim/axis/vismach/melfa-sim/remap_subs/428remap.ngc:' \ "$manifest_file" >>"$cli_manifest_with_unused_remap" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_unused_remap" >/dev/null 2>&1; then echo "switchkins remap generator accepted unused LinuxCNC M428 remap source" >&2 exit 1 fi cp "$cli_manifest" "$cli_manifest_with_unused_tooldata" grep -F \ 'tooldata:configs/sim/axis/vismach/melfa-sim/melfa.tbl:' \ "$manifest_file" >>"$cli_manifest_with_unused_tooldata" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_manifest_with_unused_tooldata" >/dev/null 2>&1; then echo "switchkins remap generator accepted unused LinuxCNC TOOL_TABLE source" >&2 exit 1 fi if ! awk -F: -v manifest="$manifest_file" ' function require(key) { expected[++expected_count] = key expected_keys[key] = 1 } BEGIN { require("config:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/xyzacb-trsrn_twp/xyzacb-trsrn.ini") require("config:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/xyzacb-trsrn_twp/xyzacb-trsrn_postgui.hal") require("config:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/xyzbca-trsrn_twp/xyzbca-trsrn.ini") require("config:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/xyzbca-trsrn_twp/xyzbca-trsrn_postgui.hal") require("remap:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/428remap.ngc") require("remap:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/429remap.ngc") require("remap:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/430remap.ngc") require("tooldata:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/xyzacb-trsrn_twp/xyzacb-trsrn.tbl") require("tooldata:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/xyzbca-trsrn_twp/xyzbca-trsrn.tbl") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/remap.py") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/toplevel.py") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/twp-helper-comp.py") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/util.py") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/g531remap.ngc") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/g533remap.ngc") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/g536remap.ngc") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/g69remap.ngc") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/on_abort_no_twp_reset.ngc") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/on_abort_with_twp_reset.ngc") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/vismach/twp_vismach.py") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/vismach/xyzacb-trsrn-gui.py") require("asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/vismach/xyzbca-trsrn-gui.py") } { key = $1 ":" $2 if (key in expected_keys) { if (key in manifest_line) { print "duplicate TRSRN switchkins source in " manifest ": " key > "/dev/stderr" duplicate = 1 } manifest_line[key] = $0 } } END { missing = 0 for (expected_index = 1; expected_index <= expected_count; expected_index++) { key = expected[expected_index] if (!(key in manifest_line)) { print "TRSRN switchkins source is not listed in " manifest ": " key > "/dev/stderr" missing = 1 continue } print manifest_line[key] } exit missing || duplicate } ' "$manifest_file" >"$cli_trsrn_manifest"; then exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/toplevel.py:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_python_toplevel_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_python_toplevel_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC PYTHON TOPLEVEL asset source" >&2 exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/util.py:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_python_import_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_python_import_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC Python import asset source" >&2 exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/python/twp-helper-comp.py:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_halcmd_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_halcmd_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC HALCMD loadusr asset source" >&2 exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/vismach/twp_vismach.py:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_halcmd_import_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_halcmd_import_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC HALCMD Python import asset source" >&2 exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/g531remap.ngc:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_g531_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_g531_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC G53.1 remap asset source" >&2 exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/g69remap.ngc:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_g69_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_g69_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC G69 remap asset source" >&2 exit 1 fi grep -Fv \ 'asset:configs/sim/axis/vismach/5axis/table-rotary_spindle-rotary-nutating/remap_subs/on_abort_no_twp_reset.ngc:' \ "$cli_trsrn_manifest" >"$cli_trsrn_without_on_abort_asset" if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ "$cli_trsrn_without_on_abort_asset" >/dev/null 2>&1; then echo "switchkins remap generator accepted manifest missing LinuxCNC ON_ABORT_COMMAND asset source" >&2 exit 1 fi if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ --all-output-dir "$cli_dir/out with space" "$cli_manifest" >"$cli_output_whitespace_log" 2>&1; then echo "switchkins remap generator accepted an output directory with whitespace" >&2 exit 1 fi if ! grep -F "switchkins remap output directory must not contain whitespace: $cli_dir/out with space" \ "$cli_output_whitespace_log" >/dev/null; then echo "switchkins remap generator did not report whitespace output directories clearly" >&2 sed -n '1,20p' "$cli_output_whitespace_log" >&2 exit 1 fi if LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh \ --all-output-dir / "$cli_manifest" >"$cli_output_root_log" 2>&1; then echo "switchkins remap generator accepted the filesystem root as output directory" >&2 exit 1 fi if ! grep -F "switchkins remap output directory must not be the filesystem root" \ "$cli_output_root_log" >/dev/null; then echo "switchkins remap generator did not report filesystem-root output directories clearly" >&2 sed -n '1,20p' "$cli_output_root_log" >&2 exit 1 fi LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --all-output-dir "$cli_dir/out" "$cli_manifest" LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh "$cli_manifest" \ >"$cli_dir/linuxcnc_switchkins_remap_table.stdout.inc" LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --config-cases "$cli_manifest" \ >"$cli_dir/linuxcnc_switchkins_remap_config_cases.stdout.inc" LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --json-cases "$cli_manifest" \ >"$cli_dir/linuxcnc_switchkins_remap_config_cases.stdout.json" for generated_file in \ out/linuxcnc_switchkins_remap_table.inc \ out/linuxcnc_switchkins_remap_config_cases.inc \ out/linuxcnc_switchkins_remap_config_cases.json \ linuxcnc_switchkins_remap_table.stdout.inc \ linuxcnc_switchkins_remap_config_cases.stdout.inc \ linuxcnc_switchkins_remap_config_cases.stdout.json do if [[ ! -s "$cli_dir/$generated_file" ]]; then echo "switchkins remap generator CLI contract did not write $generated_file" >&2 exit 1 fi done diff -u "$cli_dir/out/linuxcnc_switchkins_remap_table.inc" \ "$cli_dir/linuxcnc_switchkins_remap_table.stdout.inc" diff -u "$cli_dir/out/linuxcnc_switchkins_remap_config_cases.inc" \ "$cli_dir/linuxcnc_switchkins_remap_config_cases.stdout.inc" diff -u "$cli_dir/out/linuxcnc_switchkins_remap_config_cases.json" \ "$cli_dir/linuxcnc_switchkins_remap_config_cases.stdout.json" ) } validate_generator_cli_contract "$manifest" generated_dir=$(mktemp -d "${TMPDIR:-/tmp}/linuxcnc_switchkins_remap.XXXXXX") trap 'rm -rf "$generated_dir"' EXIT LINUXCNC_ROOT="$linuxcnc_root" ./generate-linuxcnc-switchkins-remap-table.sh --all-output-dir "$generated_dir" "$manifest" validate_json() { local file=$1 local label=$2 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$file" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, list) or not data: raise SystemExit("expected non-empty JSON array") seen = set() seen_values = {} for index, entry in enumerate(data): if not isinstance(entry, dict): raise SystemExit(f"entry {index} is not an object") if set(entry) != {"field", "value", "m428", "m429", "m430"}: raise SystemExit(f"entry {index} has unexpected keys") if not isinstance(entry["field"], str) or not entry["field"]: raise SystemExit(f"entry {index} has invalid field") if not isinstance(entry["value"], str) or not entry["value"]: raise SystemExit(f"entry {index} has invalid value") for key in ("m428", "m429", "m430"): if not isinstance(entry[key], int): raise SystemExit(f"entry {index} has non-integer {key}") case_key = (entry["field"], entry["value"], entry["m428"], entry["m429"], entry["m430"]) if case_key in seen: raise SystemExit(f"entry {index} duplicates an earlier case") seen.add(case_key) value_key = (entry["field"], entry["value"]) types = (entry["m428"], entry["m429"], entry["m430"]) if value_key in seen_values and seen_values[value_key] != types: raise SystemExit(f"entry {index} has ambiguous field/value mapping") seen_values[value_key] = types PY then echo "invalid switchkins remap JSON: $label" >&2 exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$file" <<'JS' const fs = require("fs"); const data = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); if (!Array.isArray(data) || data.length === 0) { throw new Error("expected non-empty JSON array"); } const seen = new Set(); const seenValues = new Map(); for (const [index, entry] of data.entries()) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new Error(`entry ${index} is not an object`); } const keys = Object.keys(entry).sort(); const expected = ["field", "m428", "m429", "m430", "value"]; if (keys.length !== expected.length || keys.some((key, i) => key !== expected[i])) { throw new Error(`entry ${index} has unexpected keys`); } if (typeof entry.field !== "string" || entry.field.length === 0) { throw new Error(`entry ${index} has invalid field`); } if (typeof entry.value !== "string" || entry.value.length === 0) { throw new Error(`entry ${index} has invalid value`); } for (const key of ["m428", "m429", "m430"]) { if (!Number.isInteger(entry[key])) { throw new Error(`entry ${index} has non-integer ${key}`); } } const caseKey = JSON.stringify([entry.field, entry.value, entry.m428, entry.m429, entry.m430]); if (seen.has(caseKey)) { throw new Error(`entry ${index} duplicates an earlier case`); } seen.add(caseKey); const valueKey = JSON.stringify([entry.field, entry.value]); const types = JSON.stringify([entry.m428, entry.m429, entry.m430]); if (seenValues.has(valueKey) && seenValues.get(valueKey) !== types) { throw new Error(`entry ${index} has ambiguous field/value mapping`); } seenValues.set(valueKey, types); } JS then echo "invalid switchkins remap JSON: $label" >&2 exit 1 fi else echo "missing python3 or node for switchkins remap JSON validation" >&2 exit 1 fi } validate_generated_header() { local file=$1 local label=$2 local expected=$3 if [[ "$(head -n 1 "$file")" != "$expected" ]]; then echo "unexpected generated switchkins remap $label header in $file" >&2 exit 1 fi } validate_generated_source_notes() { local file=$1 local label=$2 shift 2 local expected for expected in "$@"; do if ! grep -Fxq "$expected" "$file"; then echo "missing generated switchkins remap $label source note in $file: $expected" >&2 exit 1 fi done } validate_cpp_table() { local file=$1 local label=$2 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$file" "$label" <<'PY' import json import re import sys file, label = sys.argv[1:] uppercase_or_space_re = re.compile(r'[A-Z\s]') row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} row_count = 0 with open(file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = row_re.match(line) if not match: continue row_count += 1 alias = json.loads(match.group(1)) if not alias: raise SystemExit(f"{label} table has empty alias at line {line_number}") if uppercase_or_space_re.search(alias): raise SystemExit(f"{label} table has unnormalized alias {alias!r} at line {line_number}") if alias in aliases: raise SystemExit( f"{label} table has duplicate alias {alias!r} at lines " f"{aliases[alias]} and {line_number}" ) aliases[alias] = line_number types = (int(match.group(2)), int(match.group(3)), int(match.group(4))) if types[0] < 0 or types[1] < 0: raise SystemExit(f"{label} table has missing M428/M429 kinstype at line {line_number}") for type_value in types: if type_value < -1: raise SystemExit(f"{label} table has invalid kinstype {type_value} at line {line_number}") if row_count == 0: raise SystemExit(f"{label} table has no rows") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$file" "$label" <<'JS' const fs = require("fs"); const [file, label] = process.argv.slice(2); const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const uppercaseOrSpaceRe = /[A-Z\s]/; const aliases = new Map(); let rowCount = 0; for (const [index, line] of fs.readFileSync(file, "utf8").split(/\r?\n/).entries()) { const lineNumber = index + 1; const match = rowRe.exec(line); if (!match) { continue; } rowCount += 1; const alias = JSON.parse(match[1]); if (!alias) { throw new Error(`${label} table has empty alias at line ${lineNumber}`); } if (uppercaseOrSpaceRe.test(alias)) { throw new Error(`${label} table has unnormalized alias ${JSON.stringify(alias)} at line ${lineNumber}`); } if (aliases.has(alias)) { throw new Error(`${label} table has duplicate alias ${JSON.stringify(alias)} at lines ${aliases.get(alias)} and ${lineNumber}`); } aliases.set(alias, lineNumber); const types = [Number(match[2]), Number(match[3]), Number(match[4])]; if (types[0] < 0 || types[1] < 0) { throw new Error(`${label} table has missing M428/M429 kinstype at line ${lineNumber}`); } for (const typeValue of types) { if (typeValue < -1) { throw new Error(`${label} table has invalid kinstype ${typeValue} at line ${lineNumber}`); } } } if (rowCount === 0) { throw new Error(`${label} table has no rows`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap table validation" >&2 exit 1 fi } validate_cpp_cases() { local file=$1 local label=$2 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$file" "$label" <<'PY' import json import re import sys file, label = sys.argv[1:] row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) seen = set() seen_values = {} row_count = 0 with open(file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = row_re.match(line) if not match: continue row_count += 1 field = json.loads(match.group(1)) value = json.loads(match.group(2)) types = (int(match.group(3)), int(match.group(4)), int(match.group(5))) if types[0] < 0 or types[1] < 0: raise SystemExit(f"{label} cases have missing M428/M429 kinstype at line {line_number}") if any(type_value < -1 for type_value in types): raise SystemExit(f"{label} cases have invalid kinstype at line {line_number}") if not field or not value: raise SystemExit(f"{label} cases have empty field/value at line {line_number}") case_key = (field, value, *types) if case_key in seen: raise SystemExit(f"{label} cases duplicate an earlier case at line {line_number}") seen.add(case_key) value_key = (field, value) if value_key in seen_values and seen_values[value_key] != types: raise SystemExit(f"{label} cases have ambiguous field/value mapping at line {line_number}") seen_values[value_key] = types if row_count == 0: raise SystemExit(f"{label} cases have no rows") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$file" "$label" <<'JS' const fs = require("fs"); const [file, label] = process.argv.slice(2); const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const seen = new Set(); const seenValues = new Map(); let rowCount = 0; for (const [index, line] of fs.readFileSync(file, "utf8").split(/\r?\n/).entries()) { const lineNumber = index + 1; const match = rowRe.exec(line); if (!match) { continue; } rowCount += 1; const field = JSON.parse(match[1]); const value = JSON.parse(match[2]); const types = [Number(match[3]), Number(match[4]), Number(match[5])]; if (types[0] < 0 || types[1] < 0) { throw new Error(`${label} cases have missing M428/M429 kinstype at line ${lineNumber}`); } if (types.some((typeValue) => typeValue < -1)) { throw new Error(`${label} cases have invalid kinstype at line ${lineNumber}`); } if (!field || !value) { throw new Error(`${label} cases have empty field/value at line ${lineNumber}`); } const caseKey = JSON.stringify([field, value, ...types]); if (seen.has(caseKey)) { throw new Error(`${label} cases duplicate an earlier case at line ${lineNumber}`); } seen.add(caseKey); const valueKey = JSON.stringify([field, value]); const typeKey = JSON.stringify(types); if (seenValues.has(valueKey) && seenValues.get(valueKey) !== typeKey) { throw new Error(`${label} cases have ambiguous field/value mapping at line ${lineNumber}`); } seenValues.set(valueKey, typeKey); } if (rowCount === 0) { throw new Error(`${label} cases have no rows`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap C++ case validation" >&2 exit 1 fi } compare_cpp_json_cases() { local cpp_file=$1 local json_file=$2 local label=$3 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys cpp_file, json_file, label = sys.argv[1:] row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) cpp_cases = [] with open(cpp_file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = row_re.match(line) if not match: continue cpp_cases.append({ "field": json.loads(match.group(1)), "value": json.loads(match.group(2)), "m428": int(match.group(3)), "m429": int(match.group(4)), "m430": int(match.group(5)), }) with open(json_file, encoding="utf-8") as handle: json_cases = json.load(handle) if cpp_cases != json_cases: raise SystemExit(f"{label} C++/JSON switchkins remap cases differ") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [cppFile, jsonFile, label] = process.argv.slice(2); const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const cppCases = []; for (const line of fs.readFileSync(cppFile, "utf8").split(/\r?\n/)) { const match = rowRe.exec(line); if (!match) { continue; } cppCases.push({ field: JSON.parse(match[1]), value: JSON.parse(match[2]), m428: Number(match[3]), m429: Number(match[4]), m430: Number(match[5]), }); } const jsonCases = JSON.parse(fs.readFileSync(jsonFile, "utf8")); if (JSON.stringify(cppCases) !== JSON.stringify(jsonCases)) { throw new Error(`${label} C++/JSON switchkins remap cases differ`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap C++/JSON comparison" >&2 exit 1 fi } extract_api_switchkins_alias_keys() { local file=$1 if command -v python3 >/dev/null 2>&1; then python3 - "$file" <<'PY' import re import sys file = sys.argv[1] pattern = re.compile(r'contains_string_value\(compact,\s*"([^"]+)",\s*alias\)') function_started = False brace_depth = 0 keys = [] with open(file, encoding="utf-8") as handle: for line in handle: if not function_started: if line.startswith("bool contains_switchkins_config_alias("): function_started = True brace_depth += line.count("{") - line.count("}") continue brace_depth += line.count("{") - line.count("}") keys.extend(pattern.findall(line)) if brace_depth <= 0: break if not keys: raise SystemExit("missing switchkins config alias keys in cnc_sim_api.cpp") for key in keys: print(key) PY elif command -v node >/dev/null 2>&1; then node - "$file" <<'JS' const fs = require("fs"); const file = process.argv[2]; const pattern = /contains_string_value\(compact,\s*"([^"]+)",\s*alias\)/g; const lines = fs.readFileSync(file, "utf8").split(/\r?\n/); let started = false; let braceDepth = 0; const keys = []; for (const line of lines) { if (!started) { if (line.startsWith("bool contains_switchkins_config_alias(")) { started = true; braceDepth += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length; } continue; } braceDepth += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length; for (const match of line.matchAll(pattern)) { keys.push(match[1]); } if (braceDepth <= 0) { break; } } if (keys.length === 0) { throw new Error("missing switchkins config alias keys in cnc_sim_api.cpp"); } for (const key of keys) { console.log(key); } JS else echo "missing python3 or node for switchkins remap alias extraction" >&2 exit 1 fi } validate_switchkins_case_fields_against_api() { local api_keys_csv=$1 local cpp_file=$2 local json_file=$3 local label=$4 if [[ -z "$api_keys_csv" ]]; then echo "missing switchkins config alias keys in core/src/cnc_sim_api.cpp" >&2 exit 1 fi if command -v python3 >/dev/null 2>&1; then if ! python3 - "$api_keys_csv" "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys api_keys_csv, cpp_file, json_file, label = sys.argv[1:] allowed = {key for key in api_keys_csv.split(",") if key} if not allowed: raise SystemExit("missing switchkins config alias keys in cnc_sim_api.cpp") row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) def load_cases(path): if path.endswith(".json"): with open(path, encoding="utf-8") as handle: return json.load(handle) with open(path, encoding="utf-8") as handle: return [ { "field": json.loads(match.group(1)), "value": json.loads(match.group(2)), "m428": int(match.group(3)), "m429": int(match.group(4)), "m430": int(match.group(5)), } for match in row_re.finditer(handle.read()) ] cases = load_cases(cpp_file) + load_cases(json_file) covered = set() for index, entry in enumerate(cases): field = entry["field"] if field.lower() not in allowed: raise SystemExit(f"{label} case {index} uses unsupported switchkins field {field!r}") covered.add(field.lower()) missing = sorted(allowed - covered) if missing: raise SystemExit(f"{label} cases do not cover switchkins API alias field(s): {missing}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$api_keys_csv" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [apiKeysCsv, cppFile, jsonFile, label] = process.argv.slice(2); const allowed = new Set(apiKeysCsv.split(",").filter(Boolean)); if (allowed.size === 0) { throw new Error("missing switchkins config alias keys in cnc_sim_api.cpp"); } const rowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const cases = []; const covered = new Set(); for (const [path, isJson] of [[cppFile, false], [jsonFile, true]]) { if (isJson) { for (const entry of JSON.parse(fs.readFileSync(path, "utf8"))) { cases.push(entry); } continue; } for (const line of fs.readFileSync(path, "utf8").split(/\r?\n/)) { const match = rowRe.exec(line); if (!match) { continue; } cases.push({ field: JSON.parse(match[1]), value: JSON.parse(match[2]), m428: Number(match[3]), m429: Number(match[4]), m430: Number(match[5]), }); } } cases.forEach((entry, index) => { if (!allowed.has(entry.field.toLowerCase())) { throw new Error(`${label} case ${index} uses unsupported switchkins field ${JSON.stringify(entry.field)}`); } covered.add(entry.field.toLowerCase()); }); const missing = [...allowed].filter((key) => !covered.has(key)).sort(); if (missing.length > 0) { throw new Error(`${label} cases do not cover switchkins API alias field(s): ${missing.join(",")}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap API alias validation" >&2 exit 1 fi } validate_case_values_against_table() { local table_file=$1 local cpp_file=$2 local json_file=$3 local label=$4 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$table_file" "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys table_file, cpp_file, json_file, label = sys.argv[1:] table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} with open(table_file, encoding="utf-8") as handle: for line in handle: match = table_row_re.match(line) if not match: continue aliases[json.loads(match.group(1))] = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) if not aliases: raise SystemExit(f"{label} table has no aliases") case_entries = [] with open(cpp_file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = case_row_re.match(line) if not match: continue case_entries.append(( f"{cpp_file}:{line_number}", json.loads(match.group(1)), json.loads(match.group(2)), (int(match.group(3)), int(match.group(4)), int(match.group(5))), )) with open(json_file, encoding="utf-8") as handle: for index, entry in enumerate(json.load(handle)): case_entries.append(( f"{json_file}:{index}", entry["field"], entry["value"], (entry["m428"], entry["m429"], entry["m430"]), )) for location, field, value, types in case_entries: normalized = "".join(ch for ch in value.lower() if not ch.isspace()) if normalized not in aliases: raise SystemExit(f"{label} case value {value!r} at {location} is not covered by the alias table") if aliases[normalized] != types: raise SystemExit( f"{label} case {field}={value!r} at {location} has kinstypes " f"{types}, but alias table has {aliases[normalized]}" ) PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$table_file" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [tableFile, cppFile, jsonFile, label] = process.argv.slice(2); const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const aliases = new Map(); for (const line of fs.readFileSync(tableFile, "utf8").split(/\r?\n/)) { const match = tableRowRe.exec(line); if (match) { aliases.set(JSON.parse(match[1]), [Number(match[2]), Number(match[3]), Number(match[4])]); } } if (aliases.size === 0) { throw new Error(`${label} table has no aliases`); } const caseEntries = []; for (const [index, line] of fs.readFileSync(cppFile, "utf8").split(/\r?\n/).entries()) { const match = caseRowRe.exec(line); if (!match) { continue; } caseEntries.push({ location: `${cppFile}:${index + 1}`, field: JSON.parse(match[1]), value: JSON.parse(match[2]), types: [Number(match[3]), Number(match[4]), Number(match[5])], }); } JSON.parse(fs.readFileSync(jsonFile, "utf8")).forEach((entry, index) => { caseEntries.push({ location: `${jsonFile}:${index}`, field: entry.field, value: entry.value, types: [entry.m428, entry.m429, entry.m430], }); }); for (const entry of caseEntries) { const { location, field, value, types } = entry; const normalized = value.toLowerCase().replace(/\s+/g, ""); if (!aliases.has(normalized)) { throw new Error(`${label} case value ${JSON.stringify(value)} at ${location} is not covered by the alias table`); } const aliasTypes = aliases.get(normalized); if (JSON.stringify(aliasTypes) !== JSON.stringify(types)) { throw new Error(`${label} case ${field}=${JSON.stringify(value)} at ${location} has kinstypes ${JSON.stringify(types)}, but alias table has ${JSON.stringify(aliasTypes)}`); } } JS then exit 1 fi else echo "missing python3 or node for switchkins remap case/table validation" >&2 exit 1 fi } validate_manifest_remap_paths_in_outputs() { local manifest_file=$1 local table_file=$2 local cpp_file=$3 local json_file=$4 local label=$5 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'PY' import json import os import re import sys linuxcnc_root, manifest_file, table_file, cpp_file, json_file, label = sys.argv[1:] linuxcnc_root = os.path.abspath(linuxcnc_root) manifest_remaps = [] manifest_inis = [] remap_assignment_re = re.compile(r'^\s*#\s*=\s*(-?\d+)\b') with open(manifest_file, encoding="utf-8") as handle: for line in handle: parts = line.rstrip("\n").split(":", 2) if len(parts) >= 2 and parts[0] == "remap": manifest_remaps.append(parts[1]) if len(parts) >= 2 and parts[0] == "config" and parts[1].endswith(".ini"): manifest_inis.append(parts[1]) if not manifest_remaps: raise SystemExit(f"{manifest_file} has no LinuxCNC remap sources") resolved_ini_remaps = set() remap_line_re = re.compile(r'\bM(428|429|430)\b.*\bngc\s*=\s*([^\s]+)', re.I) for source_path in manifest_inis: ini_path = os.path.join(linuxcnc_root, source_path) with open(ini_path, encoding="utf-8") as handle: section = "" subroutine_paths = [] remap_ngc_names = [] for raw_line in handle: line = re.sub(r'[;#].*$', '', raw_line).strip() if not line: continue if line.startswith("[") and line.endswith("]"): section = line[1:-1].strip().lower() continue if section != "rs274ngc" or "=" not in line: continue key, value = line.split("=", 1) key = key.strip().lower() value = value.strip() if key == "subroutine_path": subroutine_paths.append(value) elif key == "remap": match = remap_line_re.search(value) if match: ngc_name = match.group(2) if not ngc_name.endswith(".ngc"): ngc_name = f"{ngc_name}.ngc" remap_ngc_names.append(ngc_name) if not remap_ngc_names: continue if not subroutine_paths: subroutine_paths = ["."] config_dir = os.path.dirname(ini_path) for ngc_name in remap_ngc_names: resolved_source = None for subroutine_path in subroutine_paths: for path_part in subroutine_path.split(":"): if not path_part: continue candidate_dir = path_part if os.path.isabs(path_part) else os.path.join(config_dir, path_part) candidate = os.path.join(candidate_dir, ngc_name) if os.path.isfile(candidate): resolved_abs = os.path.abspath(candidate) rel = os.path.relpath(resolved_abs, linuxcnc_root) if rel.startswith("..") or os.path.isabs(rel): raise SystemExit(f"LinuxCNC remap source escapes LinuxCNC root: {resolved_abs}") resolved_source = rel break if resolved_source: break if not resolved_source: raise SystemExit(f"{label} could not resolve LinuxCNC REMAP ngc source {ngc_name} from {source_path}") resolved_ini_remaps.add(resolved_source) manifest_remap_set = set(manifest_remaps) missing_ini_remaps = sorted(resolved_ini_remaps - manifest_remap_set) unused_manifest_remaps = sorted(manifest_remap_set - resolved_ini_remaps) if missing_ini_remaps: raise SystemExit(f"{label} manifest is missing LinuxCNC INI-resolved remap source(s): {missing_ini_remaps}") if unused_manifest_remaps: raise SystemExit(f"{label} manifest remap source(s) are not resolved from LinuxCNC INI REMAP/SUBROUTINE_PATH entries: {unused_manifest_remaps}") remap_group_types = {} for path in manifest_remaps: basename = os.path.basename(path) mcode = int(basename[:3]) with open(os.path.join(linuxcnc_root, path), encoding="utf-8") as handle: kinstypes = [ int(match.group(1)) for match in map(remap_assignment_re.match, handle) if match ] if len(kinstypes) != 1: raise SystemExit(f"expected exactly one # assignment in {path}, found {len(kinstypes)}") remap_group_types.setdefault(os.path.dirname(path), {})[mcode] = kinstypes[0] expected_types = { path: ( remap_group_types[os.path.dirname(path)].get(428, -1), remap_group_types[os.path.dirname(path)].get(429, -1), remap_group_types[os.path.dirname(path)].get(430, -1), ) for path in manifest_remaps } table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} with open(table_file, encoding="utf-8") as handle: for line in handle: match = table_row_re.match(line) if match: aliases[json.loads(match.group(1))] = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) case_remaps = {} required_subroutine_case_fields = { "SUBROUTINE_PATH", "subroutinePath", "subroutinepath", "subroutine_path", } case_subroutine_paths = { field: {} for field in required_subroutine_case_fields } with open(cpp_file, encoding="utf-8") as handle: for line in handle: match = case_row_re.match(line) if not match: continue raw_field = json.loads(match.group(1)) field = raw_field.lower() value = json.loads(match.group(2)) types = (int(match.group(3)), int(match.group(4)), int(match.group(5))) if field == "remap": case_remaps[value] = types if raw_field in required_subroutine_case_fields: case_subroutine_paths[raw_field][value] = types with open(json_file, encoding="utf-8") as handle: for entry in json.load(handle): raw_field = entry["field"] field = raw_field.lower() types = (entry["m428"], entry["m429"], entry["m430"]) if field == "remap": case_remaps[entry["value"]] = types if raw_field in required_subroutine_case_fields: case_subroutine_paths[raw_field][entry["value"]] = types missing_aliases = [path for path in manifest_remaps if path not in aliases] missing_cases = [path for path in manifest_remaps if path not in case_remaps] if missing_aliases: raise SystemExit(f"{label} table is missing LinuxCNC remap source alias(es): {missing_aliases}") if missing_cases: raise SystemExit(f"{label} cases are missing LinuxCNC remap source path(s): {missing_cases}") expected_subroutine_types = { os.path.dirname(path): expected_types[path] for path in manifest_remaps } missing_subroutine_cases = [ (field, path) for field in sorted(required_subroutine_case_fields) for path in expected_subroutine_types if path not in case_subroutine_paths[field] ] if missing_subroutine_cases: raise SystemExit( f"{label} cases are missing LinuxCNC SUBROUTINE_PATH remap dir(s): {missing_subroutine_cases}" ) wrong_alias_types = [ (path, expected_types[path], aliases[path]) for path in manifest_remaps if path in aliases and aliases[path] != expected_types[path] ] wrong_case_types = [ (path, expected_types[path], case_remaps[path]) for path in manifest_remaps if path in case_remaps and case_remaps[path] != expected_types[path] ] wrong_subroutine_types = [ (field, path, expected_subroutine_types[path], case_subroutine_paths[field][path]) for field in sorted(required_subroutine_case_fields) for path in expected_subroutine_types if path in case_subroutine_paths[field] and case_subroutine_paths[field][path] != expected_subroutine_types[path] ] if wrong_alias_types: raise SystemExit(f"{label} table has LinuxCNC remap source kinstype mismatch(es): {wrong_alias_types}") if wrong_case_types: raise SystemExit(f"{label} cases have LinuxCNC remap source kinstype mismatch(es): {wrong_case_types}") if wrong_subroutine_types: raise SystemExit(f"{label} cases have LinuxCNC SUBROUTINE_PATH kinstype mismatch(es): {wrong_subroutine_types}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile, tableFile, cppFile, jsonFile, label] = process.argv.slice(2); const manifestRemaps = fs.readFileSync(manifestFile, "utf8") .split(/\r?\n/) .map((line) => line.split(":", 3)) .filter((parts) => parts.length >= 2 && parts[0] === "remap") .map((parts) => parts[1]); const manifestInis = fs.readFileSync(manifestFile, "utf8") .split(/\r?\n/) .map((line) => line.split(":", 3)) .filter((parts) => parts.length >= 2 && parts[0] === "config" && parts[1].endsWith(".ini")) .map((parts) => parts[1]); if (manifestRemaps.length === 0) { throw new Error(`${manifestFile} has no LinuxCNC remap sources`); } const linuxcncRootAbs = path.resolve(linuxcncRoot); const resolvedIniRemaps = new Set(); const remapLineRe = /\bM(428|429|430)\b.*\bngc\s*=\s*([^\s]+)/i; for (const sourcePath of manifestInis) { const iniPath = path.join(linuxcncRootAbs, sourcePath); let section = ""; const subroutinePaths = []; const remapNgcNames = []; for (const rawLine of fs.readFileSync(iniPath, "utf8").split(/\r?\n/)) { const line = rawLine.replace(/[;#].*$/, "").trim(); if (line.length === 0) { continue; } if (line.startsWith("[") && line.endsWith("]")) { section = line.slice(1, -1).trim().toLowerCase(); continue; } if (section !== "rs274ngc" || !line.includes("=")) { continue; } const equals = line.indexOf("="); const key = line.slice(0, equals).trim().toLowerCase(); const value = line.slice(equals + 1).trim(); if (key === "subroutine_path") { subroutinePaths.push(value); } else if (key === "remap") { const match = remapLineRe.exec(value); if (match) { const ngcName = match[2].endsWith(".ngc") ? match[2] : `${match[2]}.ngc`; remapNgcNames.push(ngcName); } } } if (remapNgcNames.length === 0) { continue; } if (subroutinePaths.length === 0) { subroutinePaths.push("."); } const configDir = path.dirname(iniPath); for (const ngcName of remapNgcNames) { let resolvedSource = null; for (const subroutinePath of subroutinePaths) { for (const pathPart of subroutinePath.split(":")) { if (!pathPart) { continue; } const candidateDir = path.isAbsolute(pathPart) ? pathPart : path.join(configDir, pathPart); const candidate = path.join(candidateDir, ngcName); if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { const resolvedAbs = path.resolve(candidate); const rel = path.relative(linuxcncRootAbs, resolvedAbs); if (rel.startsWith("..") || path.isAbsolute(rel)) { throw new Error(`LinuxCNC remap source escapes LinuxCNC root: ${resolvedAbs}`); } resolvedSource = rel; break; } } if (resolvedSource) { break; } } if (!resolvedSource) { throw new Error(`${label} could not resolve LinuxCNC REMAP ngc source ${ngcName} from ${sourcePath}`); } resolvedIniRemaps.add(resolvedSource); } } const manifestRemapSet = new Set(manifestRemaps); const missingIniRemaps = [...resolvedIniRemaps].filter((sourcePath) => !manifestRemapSet.has(sourcePath)).sort(); const unusedManifestRemaps = manifestRemaps.filter((sourcePath) => !resolvedIniRemaps.has(sourcePath)).sort(); if (missingIniRemaps.length > 0) { throw new Error(`${label} manifest is missing LinuxCNC INI-resolved remap source(s): ${missingIniRemaps.join(",")}`); } if (unusedManifestRemaps.length > 0) { throw new Error(`${label} manifest remap source(s) are not resolved from LinuxCNC INI REMAP/SUBROUTINE_PATH entries: ${unusedManifestRemaps.join(",")}`); } const remapAssignmentRe = /^\s*#\s*=\s*(-?\d+)\b/; const remapGroupTypes = new Map(); for (const sourcePath of manifestRemaps) { const basename = path.basename(sourcePath); const mcode = Number(basename.slice(0, 3)); const kinstypes = fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8") .split(/\r?\n/) .map((line) => remapAssignmentRe.exec(line)) .filter(Boolean) .map((match) => Number(match[1])); if (kinstypes.length !== 1) { throw new Error(`expected exactly one # assignment in ${sourcePath}, found ${kinstypes.length}`); } const dirname = path.dirname(sourcePath); if (!remapGroupTypes.has(dirname)) { remapGroupTypes.set(dirname, new Map()); } remapGroupTypes.get(dirname).set(mcode, kinstypes[0]); } const expectedTypes = new Map(); for (const sourcePath of manifestRemaps) { const typesByMcode = remapGroupTypes.get(path.dirname(sourcePath)); expectedTypes.set(sourcePath, [ typesByMcode.get(428) ?? -1, typesByMcode.get(429) ?? -1, typesByMcode.get(430) ?? -1, ]); } const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const aliases = new Map(); for (const line of fs.readFileSync(tableFile, "utf8").split(/\r?\n/)) { const match = tableRowRe.exec(line); if (match) { aliases.set(JSON.parse(match[1]), [Number(match[2]), Number(match[3]), Number(match[4])]); } } const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseRemaps = new Map(); const requiredSubroutineCaseFields = ["SUBROUTINE_PATH", "subroutinePath", "subroutinepath", "subroutine_path"]; const caseSubroutinePaths = new Map(requiredSubroutineCaseFields.map((field) => [field, new Map()])); for (const line of fs.readFileSync(cppFile, "utf8").split(/\r?\n/)) { const match = caseRowRe.exec(line); if (!match) { continue; } const rawField = JSON.parse(match[1]); const field = rawField.toLowerCase(); const value = JSON.parse(match[2]); const types = [Number(match[3]), Number(match[4]), Number(match[5])]; if (field === "remap") { caseRemaps.set(value, types); } if (caseSubroutinePaths.has(rawField)) { caseSubroutinePaths.get(rawField).set(value, types); } } for (const entry of JSON.parse(fs.readFileSync(jsonFile, "utf8"))) { const rawField = entry.field; const field = rawField.toLowerCase(); const types = [entry.m428, entry.m429, entry.m430]; if (field === "remap") { caseRemaps.set(entry.value, types); } if (caseSubroutinePaths.has(rawField)) { caseSubroutinePaths.get(rawField).set(entry.value, types); } } const missingAliases = manifestRemaps.filter((sourcePath) => !aliases.has(sourcePath)); const missingCases = manifestRemaps.filter((sourcePath) => !caseRemaps.has(sourcePath)); if (missingAliases.length > 0) { throw new Error(`${label} table is missing LinuxCNC remap source alias(es): ${missingAliases.join(",")}`); } if (missingCases.length > 0) { throw new Error(`${label} cases are missing LinuxCNC remap source path(s): ${missingCases.join(",")}`); } const expectedSubroutineTypes = new Map(); for (const sourcePath of manifestRemaps) { expectedSubroutineTypes.set(path.dirname(sourcePath), expectedTypes.get(sourcePath)); } const missingSubroutineCases = requiredSubroutineCaseFields.flatMap((field) => [...expectedSubroutineTypes.keys()] .filter((sourcePath) => !caseSubroutinePaths.get(field).has(sourcePath)) .map((sourcePath) => `${field}:${sourcePath}`) ); if (missingSubroutineCases.length > 0) { throw new Error(`${label} cases are missing LinuxCNC SUBROUTINE_PATH remap dir(s): ${missingSubroutineCases.join(",")}`); } const wrongAliasTypes = manifestRemaps .filter((sourcePath) => aliases.has(sourcePath)) .filter((sourcePath) => JSON.stringify(aliases.get(sourcePath)) !== JSON.stringify(expectedTypes.get(sourcePath))) .map((sourcePath) => `${sourcePath}: expected ${JSON.stringify(expectedTypes.get(sourcePath))}, got ${JSON.stringify(aliases.get(sourcePath))}`); const wrongCaseTypes = manifestRemaps .filter((sourcePath) => caseRemaps.has(sourcePath)) .filter((sourcePath) => JSON.stringify(caseRemaps.get(sourcePath)) !== JSON.stringify(expectedTypes.get(sourcePath))) .map((sourcePath) => `${sourcePath}: expected ${JSON.stringify(expectedTypes.get(sourcePath))}, got ${JSON.stringify(caseRemaps.get(sourcePath))}`); const wrongSubroutineTypes = [...expectedSubroutineTypes.keys()] .flatMap((sourcePath) => requiredSubroutineCaseFields .filter((field) => caseSubroutinePaths.get(field).has(sourcePath)) .filter((field) => JSON.stringify(caseSubroutinePaths.get(field).get(sourcePath)) !== JSON.stringify(expectedSubroutineTypes.get(sourcePath))) .map((field) => `${field}:${sourcePath}: expected ${JSON.stringify(expectedSubroutineTypes.get(sourcePath))}, got ${JSON.stringify(caseSubroutinePaths.get(field).get(sourcePath))}`) ); if (wrongAliasTypes.length > 0) { throw new Error(`${label} table has LinuxCNC remap source kinstype mismatch(es): ${wrongAliasTypes.join("; ")}`); } if (wrongCaseTypes.length > 0) { throw new Error(`${label} cases have LinuxCNC remap source kinstype mismatch(es): ${wrongCaseTypes.join("; ")}`); } if (wrongSubroutineTypes.length > 0) { throw new Error(`${label} cases have LinuxCNC SUBROUTINE_PATH kinstype mismatch(es): ${wrongSubroutineTypes.join("; ")}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap manifest output validation" >&2 exit 1 fi } validate_manifest_hal_paths_in_outputs() { local manifest_file=$1 local table_file=$2 local cpp_file=$3 local json_file=$4 local label=$5 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'PY' import json import os import re import sys linuxcnc_root, manifest_file, table_file, cpp_file, json_file, label = sys.argv[1:] linuxcnc_root = os.path.abspath(linuxcnc_root) manifest_hals = [] manifest_inis = [] with open(manifest_file, encoding="utf-8") as handle: for line in handle: parts = line.rstrip("\n").split(":", 2) if len(parts) >= 2 and parts[0] == "config" and parts[1].endswith(".hal"): manifest_hals.append(parts[1]) if len(parts) >= 2 and parts[0] == "config" and parts[1].endswith(".ini"): manifest_inis.append(parts[1]) if not manifest_hals: raise SystemExit(f"{manifest_file} has no LinuxCNC HAL config sources") expected_hal_roles = {} remap_re = re.compile(r'\bREMAP\s*=\s*M(428|429|430)(?:\D|$)', re.I) hal_entry_re = re.compile(r'^([^=]+)=(.*)$') for source_path in manifest_inis: ini_path = os.path.join(linuxcnc_root, source_path) with open(ini_path, encoding="utf-8") as handle: ini_text = handle.read() if not remap_re.search(ini_text): continue section = "" for raw_line in ini_text.splitlines(): line = re.sub(r'[;#].*$', '', raw_line).strip() if not line: continue if line.startswith("[") and line.endswith("]"): section = line[1:-1].strip().lower() continue if section != "hal": continue match = hal_entry_re.match(line) if not match: continue key = match.group(1).strip().lower() if key not in {"halfile", "postgui_halfile"}: continue value = match.group(2).strip() if not value or value.startswith("LIB:"): continue resolved = value if os.path.isabs(value) else os.path.join(os.path.dirname(ini_path), value) if not os.path.isfile(resolved): continue resolved_abs = os.path.abspath(resolved) rel = os.path.relpath(resolved_abs, linuxcnc_root) if rel.startswith("..") or os.path.isabs(rel): raise SystemExit(f"LinuxCNC HAL source escapes LinuxCNC root: {resolved_abs}") expected_hal_roles.setdefault(rel, set()).add("postgui" if key == "postgui_halfile" else "hal") unexpected_hals = [path for path in manifest_hals if path not in expected_hal_roles] if unexpected_hals: raise SystemExit(f"{label} manifest HAL source(s) are not referenced by LinuxCNC switchkins INI HALFILE/POSTGUI_HALFILE entries: {unexpected_hals}") table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} with open(table_file, encoding="utf-8") as handle: for line in handle: match = table_row_re.match(line) if match: aliases[json.loads(match.group(1))] = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) case_sources = {} hal_fields = { "HALFILE", "halFile", "halfile", "hal_file", } postgui_hal_fields = { "POSTGUI_HALFILE", "postguiHalFile", "postguihalfile", "postgui_hal_file", "postgui_halfile", } hal_case_sources = { field: {} for field in hal_fields } postgui_hal_case_sources = { field: {} for field in postgui_hal_fields } case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) with open(cpp_file, encoding="utf-8") as handle: for line in handle: match = case_row_re.match(line) if not match: continue raw_field = json.loads(match.group(1)) value = json.loads(match.group(2)) types = ( int(match.group(3)), int(match.group(4)), int(match.group(5)), ) if raw_field in hal_fields: case_sources[value] = types hal_case_sources[raw_field][value] = types if raw_field in postgui_hal_fields: case_sources[value] = types postgui_hal_case_sources[raw_field][value] = types with open(json_file, encoding="utf-8") as handle: for entry in json.load(handle): types = (entry["m428"], entry["m429"], entry["m430"]) if entry["field"] in hal_fields: case_sources[entry["value"]] = types hal_case_sources[entry["field"]][entry["value"]] = types if entry["field"] in postgui_hal_fields: case_sources[entry["value"]] = types postgui_hal_case_sources[entry["field"]][entry["value"]] = types missing_aliases = [path for path in manifest_hals if path not in aliases] missing_cases = [path for path in manifest_hals if path not in case_sources] missing_role_cases = [ (field, path, role) for path in manifest_hals for role in sorted(expected_hal_roles[path]) for field in sorted(postgui_hal_fields if role == "postgui" else hal_fields) if path not in (postgui_hal_case_sources[field] if role == "postgui" else hal_case_sources[field]) ] if missing_aliases: raise SystemExit(f"{label} table is missing LinuxCNC HAL source alias(es): {missing_aliases}") if missing_cases: raise SystemExit(f"{label} cases are missing LinuxCNC HAL source path(s): {missing_cases}") if missing_role_cases: raise SystemExit(f"{label} cases are missing LinuxCNC HAL source path(s) in the matching HALFILE/POSTGUI_HALFILE field group: {missing_role_cases}") wrong_types = [ (path, aliases[path], case_sources[path]) for path in manifest_hals if path in aliases and path in case_sources and aliases[path] != case_sources[path] ] wrong_role_types = [ (field, path, role, aliases[path], (postgui_hal_case_sources[field] if role == "postgui" else hal_case_sources[field])[path]) for path in manifest_hals for role in sorted(expected_hal_roles[path]) for field in sorted(postgui_hal_fields if role == "postgui" else hal_fields) if path in aliases and path in (postgui_hal_case_sources[field] if role == "postgui" else hal_case_sources[field]) and aliases[path] != (postgui_hal_case_sources[field] if role == "postgui" else hal_case_sources[field])[path] ] if wrong_types: raise SystemExit(f"{label} HAL source kinstype mismatch(es): {wrong_types}") if wrong_role_types: raise SystemExit(f"{label} HAL source field kinstype mismatch(es): {wrong_role_types}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRootArg, manifestFile, tableFile, cppFile, jsonFile, label] = process.argv.slice(2); const linuxcncRoot = path.resolve(linuxcncRootArg); const manifestHals = fs.readFileSync(manifestFile, "utf8") .split(/\r?\n/) .map((line) => line.split(":", 3)) .filter((parts) => parts.length >= 2 && parts[0] === "config" && parts[1].endsWith(".hal")) .map((parts) => parts[1]); const manifestInis = fs.readFileSync(manifestFile, "utf8") .split(/\r?\n/) .map((line) => line.split(":", 3)) .filter((parts) => parts.length >= 2 && parts[0] === "config" && parts[1].endsWith(".ini")) .map((parts) => parts[1]); if (manifestHals.length === 0) { throw new Error(`${manifestFile} has no LinuxCNC HAL config sources`); } const expectedHalRoles = new Map(); const remapRe = /\bREMAP\s*=\s*M(428|429|430)(?:\D|$)/i; for (const sourcePath of manifestInis) { const iniPath = path.join(linuxcncRoot, sourcePath); const iniText = fs.readFileSync(iniPath, "utf8"); if (!remapRe.test(iniText)) { continue; } let section = ""; for (const rawLine of iniText.split(/\r?\n/)) { const line = rawLine.replace(/[;#].*$/, "").trim(); if (line.length === 0) { continue; } if (line.startsWith("[") && line.endsWith("]")) { section = line.slice(1, -1).trim().toLowerCase(); continue; } if (section !== "hal" || !line.includes("=")) { continue; } const equals = line.indexOf("="); const key = line.slice(0, equals).trim().toLowerCase(); if (key !== "halfile" && key !== "postgui_halfile") { continue; } const value = line.slice(equals + 1).trim(); if (!value || value.startsWith("LIB:")) { continue; } const resolved = path.isAbsolute(value) ? value : path.join(path.dirname(iniPath), value); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { continue; } const resolvedAbs = path.resolve(resolved); const rel = path.relative(linuxcncRoot, resolvedAbs); if (rel.startsWith("..") || path.isAbsolute(rel)) { throw new Error(`LinuxCNC HAL source escapes LinuxCNC root: ${resolvedAbs}`); } if (!expectedHalRoles.has(rel)) { expectedHalRoles.set(rel, new Set()); } expectedHalRoles.get(rel).add(key === "postgui_halfile" ? "postgui" : "hal"); } } const unexpectedHals = manifestHals.filter((sourcePath) => !expectedHalRoles.has(sourcePath)); if (unexpectedHals.length > 0) { throw new Error(`${label} manifest HAL source(s) are not referenced by LinuxCNC switchkins INI HALFILE/POSTGUI_HALFILE entries: ${unexpectedHals.join(",")}`); } const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const aliases = new Map(); for (const line of fs.readFileSync(tableFile, "utf8").split(/\r?\n/)) { const match = tableRowRe.exec(line); if (match) { aliases.set(JSON.parse(match[1]), [Number(match[2]), Number(match[3]), Number(match[4])]); } } const halFields = ["HALFILE", "halFile", "halfile", "hal_file"]; const postguiHalFields = ["POSTGUI_HALFILE", "postguiHalFile", "postguihalfile", "postgui_hal_file", "postgui_halfile"]; const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseSources = new Map(); const halCaseSources = new Map(halFields.map((field) => [field, new Map()])); const postguiHalCaseSources = new Map(postguiHalFields.map((field) => [field, new Map()])); for (const line of fs.readFileSync(cppFile, "utf8").split(/\r?\n/)) { const match = caseRowRe.exec(line); if (!match) { continue; } const rawField = JSON.parse(match[1]); const value = JSON.parse(match[2]); const types = [Number(match[3]), Number(match[4]), Number(match[5])]; if (halCaseSources.has(rawField)) { caseSources.set(value, types); halCaseSources.get(rawField).set(value, types); } if (postguiHalCaseSources.has(rawField)) { caseSources.set(value, types); postguiHalCaseSources.get(rawField).set(value, types); } } for (const entry of JSON.parse(fs.readFileSync(jsonFile, "utf8"))) { const types = [entry.m428, entry.m429, entry.m430]; if (halCaseSources.has(entry.field)) { caseSources.set(entry.value, types); halCaseSources.get(entry.field).set(entry.value, types); } if (postguiHalCaseSources.has(entry.field)) { caseSources.set(entry.value, types); postguiHalCaseSources.get(entry.field).set(entry.value, types); } } const missingAliases = manifestHals.filter((sourcePath) => !aliases.has(sourcePath)); const missingCases = manifestHals.filter((sourcePath) => !caseSources.has(sourcePath)); const missingRoleCases = manifestHals.flatMap((sourcePath) => [...expectedHalRoles.get(sourcePath)].sort() .flatMap((role) => (role === "postgui" ? postguiHalFields : halFields) .filter((field) => !(role === "postgui" ? postguiHalCaseSources : halCaseSources).get(field).has(sourcePath)) .map((field) => `${field}:${sourcePath} ${role}`) ) ); if (missingAliases.length > 0) { throw new Error(`${label} table is missing LinuxCNC HAL source alias(es): ${missingAliases.join(",")}`); } if (missingCases.length > 0) { throw new Error(`${label} cases are missing LinuxCNC HAL source path(s): ${missingCases.join(",")}`); } if (missingRoleCases.length > 0) { throw new Error(`${label} cases are missing LinuxCNC HAL source path(s) in the matching HALFILE/POSTGUI_HALFILE field group: ${missingRoleCases.join(",")}`); } const wrongTypes = manifestHals .filter((sourcePath) => aliases.has(sourcePath) && caseSources.has(sourcePath)) .filter((sourcePath) => JSON.stringify(aliases.get(sourcePath)) !== JSON.stringify(caseSources.get(sourcePath))) .map((sourcePath) => `${sourcePath}: table ${JSON.stringify(aliases.get(sourcePath))}, case ${JSON.stringify(caseSources.get(sourcePath))}`); const wrongRoleTypes = manifestHals.flatMap((sourcePath) => [...expectedHalRoles.get(sourcePath)].sort() .flatMap((role) => (role === "postgui" ? postguiHalFields : halFields) .filter((field) => aliases.has(sourcePath)) .filter((field) => (role === "postgui" ? postguiHalCaseSources : halCaseSources).get(field).has(sourcePath)) .filter((field) => JSON.stringify(aliases.get(sourcePath)) !== JSON.stringify((role === "postgui" ? postguiHalCaseSources : halCaseSources).get(field).get(sourcePath))) .map((field) => `${field}:${sourcePath} ${role}: table ${JSON.stringify(aliases.get(sourcePath))}, case ${JSON.stringify((role === "postgui" ? postguiHalCaseSources : halCaseSources).get(field).get(sourcePath))}`) ) ); if (wrongTypes.length > 0) { throw new Error(`${label} HAL source kinstype mismatch(es): ${wrongTypes.join("; ")}`); } if (wrongRoleTypes.length > 0) { throw new Error(`${label} HAL source field kinstype mismatch(es): ${wrongRoleTypes.join("; ")}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins HAL manifest output validation" >&2 exit 1 fi } validate_manifest_ini_paths_in_outputs() { local manifest_file=$1 local table_file=$2 local cpp_file=$3 local json_file=$4 local label=$5 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'PY' import json import re import sys manifest_file, table_file, cpp_file, json_file, label = sys.argv[1:] manifest_inis = [] with open(manifest_file, encoding="utf-8") as handle: for line in handle: parts = line.rstrip("\n").split(":", 2) if len(parts) >= 2 and parts[0] == "config" and parts[1].endswith(".ini"): manifest_inis.append(parts[1]) if not manifest_inis: raise SystemExit(f"{manifest_file} has no LinuxCNC INI config sources") table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} with open(table_file, encoding="utf-8") as handle: for line in handle: match = table_row_re.match(line) if match: aliases[json.loads(match.group(1))] = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) ini_fields = { "INI_FILE_NAME", "config", "configPath", "configpath", "config_path", "ini", "iniFile", "inifile", "iniFileName", "inifilename", "ini_file", "ini_file_name", } case_sources = { field: {} for field in ini_fields } case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) with open(cpp_file, encoding="utf-8") as handle: for line in handle: match = case_row_re.match(line) if not match: continue raw_field = json.loads(match.group(1)) if raw_field in ini_fields: case_sources[raw_field][json.loads(match.group(2))] = ( int(match.group(3)), int(match.group(4)), int(match.group(5)), ) with open(json_file, encoding="utf-8") as handle: for entry in json.load(handle): if entry["field"] in ini_fields: case_sources[entry["field"]][entry["value"]] = (entry["m428"], entry["m429"], entry["m430"]) missing_aliases = [path for path in manifest_inis if path not in aliases] missing_cases = [ (field, path) for field in sorted(ini_fields) for path in manifest_inis if path not in case_sources[field] ] if missing_aliases: raise SystemExit(f"{label} table is missing LinuxCNC INI source alias(es): {missing_aliases}") if missing_cases: raise SystemExit(f"{label} cases are missing LinuxCNC INI source path(s): {missing_cases}") wrong_types = [ (field, path, aliases[path], case_sources[field][path]) for field in sorted(ini_fields) for path in manifest_inis if path in aliases and path in case_sources[field] and aliases[path] != case_sources[field][path] ] if wrong_types: raise SystemExit(f"{label} INI source kinstype mismatch(es): {wrong_types}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const [manifestFile, tableFile, cppFile, jsonFile, label] = process.argv.slice(2); const manifestInis = fs.readFileSync(manifestFile, "utf8") .split(/\r?\n/) .map((line) => line.split(":", 3)) .filter((parts) => parts.length >= 2 && parts[0] === "config" && parts[1].endsWith(".ini")) .map((parts) => parts[1]); if (manifestInis.length === 0) { throw new Error(`${manifestFile} has no LinuxCNC INI config sources`); } const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const aliases = new Map(); for (const line of fs.readFileSync(tableFile, "utf8").split(/\r?\n/)) { const match = tableRowRe.exec(line); if (match) { aliases.set(JSON.parse(match[1]), [Number(match[2]), Number(match[3]), Number(match[4])]); } } const iniFields = ["INI_FILE_NAME", "config", "configPath", "configpath", "config_path", "ini", "iniFile", "inifile", "iniFileName", "inifilename", "ini_file", "ini_file_name"]; const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseSources = new Map(iniFields.map((field) => [field, new Map()])); for (const line of fs.readFileSync(cppFile, "utf8").split(/\r?\n/)) { const match = caseRowRe.exec(line); if (!match) { continue; } const rawField = JSON.parse(match[1]); if (caseSources.has(rawField)) { caseSources.get(rawField).set(JSON.parse(match[2]), [Number(match[3]), Number(match[4]), Number(match[5])]); } } for (const entry of JSON.parse(fs.readFileSync(jsonFile, "utf8"))) { if (caseSources.has(entry.field)) { caseSources.get(entry.field).set(entry.value, [entry.m428, entry.m429, entry.m430]); } } const missingAliases = manifestInis.filter((sourcePath) => !aliases.has(sourcePath)); const missingCases = iniFields.flatMap((field) => manifestInis .filter((sourcePath) => !caseSources.get(field).has(sourcePath)) .map((sourcePath) => `${field}:${sourcePath}`) ); if (missingAliases.length > 0) { throw new Error(`${label} table is missing LinuxCNC INI source alias(es): ${missingAliases.join(",")}`); } if (missingCases.length > 0) { throw new Error(`${label} cases are missing LinuxCNC INI source path(s): ${missingCases.join(",")}`); } const wrongTypes = iniFields.flatMap((field) => manifestInis .filter((sourcePath) => aliases.has(sourcePath) && caseSources.get(field).has(sourcePath)) .filter((sourcePath) => JSON.stringify(aliases.get(sourcePath)) !== JSON.stringify(caseSources.get(field).get(sourcePath))) .map((sourcePath) => `${field}:${sourcePath}: table ${JSON.stringify(aliases.get(sourcePath))}, case ${JSON.stringify(caseSources.get(field).get(sourcePath))}`) ); if (wrongTypes.length > 0) { throw new Error(`${label} INI source kinstype mismatch(es): ${wrongTypes.join("; ")}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins INI manifest output validation" >&2 exit 1 fi } validate_manifest_ini_identity_in_outputs() { local manifest_file=$1 local table_file=$2 local cpp_file=$3 local json_file=$4 local label=$5 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$linuxcnc_root" "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'PY' import json import os import re import sys linuxcnc_root, manifest_file, table_file, cpp_file, json_file, label = sys.argv[1:] def normalize_alias(value): return "".join(ch for ch in value.lower() if not ch.isspace()) def read_ini_identity(path): values = {} with open(os.path.join(linuxcnc_root, path), encoding="utf-8") as handle: for line in handle: line = re.sub(r'\s*[#;].*$', '', line).strip() if "=" not in line: continue key, value = line.split("=", 1) key = key.strip().lower() value = value.strip() if key in {"machine", "kinematics"} and value and key not in values: values[key] = value return values manifest_inis = [] with open(manifest_file, encoding="utf-8") as handle: for line in handle: parts = line.rstrip("\n").split(":", 2) if len(parts) >= 2 and parts[0] == "config" and parts[1].endswith(".ini"): manifest_inis.append(parts[1]) if not manifest_inis: raise SystemExit(f"{manifest_file} has no LinuxCNC INI config sources") table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) aliases = {} with open(table_file, encoding="utf-8") as handle: for line in handle: match = table_row_re.match(line) if match: aliases[json.loads(match.group(1))] = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) case_sources = {} with open(cpp_file, encoding="utf-8") as handle: for line in handle: match = case_row_re.match(line) if match: case_sources[(json.loads(match.group(1)).lower(), json.loads(match.group(2)))] = ( int(match.group(3)), int(match.group(4)), int(match.group(5)), ) with open(json_file, encoding="utf-8") as handle: for entry in json.load(handle): case_sources[(entry["field"].lower(), entry["value"])] = ( entry["m428"], entry["m429"], entry["m430"], ) missing_cases = [] missing_aliases = [] wrong_case_types = [] wrong_alias_types = [] for ini_path in manifest_inis: if ini_path not in aliases: continue expected_types = aliases[ini_path] for field, value in read_ini_identity(ini_path).items(): case_key = (field, value) alias = normalize_alias(value) if case_key not in case_sources: missing_cases.append((ini_path, field, value)) elif case_sources[case_key] != expected_types: wrong_case_types.append((ini_path, field, value, expected_types, case_sources[case_key])) if alias not in aliases: missing_aliases.append((ini_path, field, value, alias)) elif aliases[alias] != expected_types: wrong_alias_types.append((ini_path, field, value, expected_types, aliases[alias])) if missing_cases: raise SystemExit(f"{label} cases are missing LinuxCNC INI MACHINE/KINEMATICS value(s): {missing_cases}") if missing_aliases: raise SystemExit(f"{label} table is missing LinuxCNC INI MACHINE/KINEMATICS alias(es): {missing_aliases}") if wrong_case_types: raise SystemExit(f"{label} cases have LinuxCNC INI MACHINE/KINEMATICS kinstype mismatch(es): {wrong_case_types}") if wrong_alias_types: raise SystemExit(f"{label} table has LinuxCNC INI MACHINE/KINEMATICS kinstype mismatch(es): {wrong_alias_types}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$linuxcnc_root" "$manifest_file" "$table_file" "$cpp_file" "$json_file" "$label" <<'JS' const fs = require("fs"); const path = require("path"); const [linuxcncRoot, manifestFile, tableFile, cppFile, jsonFile, label] = process.argv.slice(2); function normalizeAlias(value) { return value.toLowerCase().replace(/\s+/g, ""); } function readIniIdentity(sourcePath) { const values = new Map(); for (const sourceLine of fs.readFileSync(path.join(linuxcncRoot, sourcePath), "utf8").split(/\r?\n/)) { const line = sourceLine.replace(/\s*[#;].*$/, "").trim(); const equals = line.indexOf("="); if (equals < 0) { continue; } const key = line.slice(0, equals).trim().toLowerCase(); const value = line.slice(equals + 1).trim(); if ((key === "machine" || key === "kinematics") && value && !values.has(key)) { values.set(key, value); } } return values; } const manifestInis = fs.readFileSync(manifestFile, "utf8") .split(/\r?\n/) .map((line) => line.split(":", 3)) .filter((parts) => parts.length >= 2 && parts[0] === "config" && parts[1].endsWith(".ini")) .map((parts) => parts[1]); if (manifestInis.length === 0) { throw new Error(`${manifestFile} has no LinuxCNC INI config sources`); } const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const aliases = new Map(); for (const line of fs.readFileSync(tableFile, "utf8").split(/\r?\n/)) { const match = tableRowRe.exec(line); if (match) { aliases.set(JSON.parse(match[1]), [Number(match[2]), Number(match[3]), Number(match[4])]); } } const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseSources = new Map(); for (const line of fs.readFileSync(cppFile, "utf8").split(/\r?\n/)) { const match = caseRowRe.exec(line); if (match) { caseSources.set(JSON.stringify([JSON.parse(match[1]).toLowerCase(), JSON.parse(match[2])]), [Number(match[3]), Number(match[4]), Number(match[5])]); } } for (const entry of JSON.parse(fs.readFileSync(jsonFile, "utf8"))) { caseSources.set(JSON.stringify([entry.field.toLowerCase(), entry.value]), [entry.m428, entry.m429, entry.m430]); } const missingCases = []; const missingAliases = []; const wrongCaseTypes = []; const wrongAliasTypes = []; for (const iniPath of manifestInis) { if (!aliases.has(iniPath)) { continue; } const expectedTypes = aliases.get(iniPath); for (const [field, value] of readIniIdentity(iniPath).entries()) { const caseKey = JSON.stringify([field, value]); const alias = normalizeAlias(value); if (!caseSources.has(caseKey)) { missingCases.push([iniPath, field, value]); } else if (JSON.stringify(caseSources.get(caseKey)) !== JSON.stringify(expectedTypes)) { wrongCaseTypes.push([iniPath, field, value, expectedTypes, caseSources.get(caseKey)]); } if (!aliases.has(alias)) { missingAliases.push([iniPath, field, value, alias]); } else if (JSON.stringify(aliases.get(alias)) !== JSON.stringify(expectedTypes)) { wrongAliasTypes.push([iniPath, field, value, expectedTypes, aliases.get(alias)]); } } } if (missingCases.length > 0) { throw new Error(`${label} cases are missing LinuxCNC INI MACHINE/KINEMATICS value(s): ${JSON.stringify(missingCases)}`); } if (missingAliases.length > 0) { throw new Error(`${label} table is missing LinuxCNC INI MACHINE/KINEMATICS alias(es): ${JSON.stringify(missingAliases)}`); } if (wrongCaseTypes.length > 0) { throw new Error(`${label} cases have LinuxCNC INI MACHINE/KINEMATICS kinstype mismatch(es): ${JSON.stringify(wrongCaseTypes)}`); } if (wrongAliasTypes.length > 0) { throw new Error(`${label} table has LinuxCNC INI MACHINE/KINEMATICS kinstype mismatch(es): ${JSON.stringify(wrongAliasTypes)}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins INI identity output validation" >&2 exit 1 fi } api_keys_csv=$(printf '%s,' $(extract_api_switchkins_alias_keys core/src/cnc_sim_api.cpp)) api_keys_csv=${api_keys_csv%,} validate_api_switchkins_alias_keys() { local keys_csv=$1 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$keys_csv" <<'PY' import sys keys = [key for key in sys.argv[1].split(",") if key] if not keys: raise SystemExit("missing switchkins config alias keys in cnc_sim_api.cpp") seen = set() for key in keys: if key != key.lower(): raise SystemExit(f"switchkins config alias key is not lowercase: {key}") if any(ch.isspace() for ch in key): raise SystemExit(f"switchkins config alias key contains whitespace: {key}") if key in seen: raise SystemExit(f"duplicate switchkins config alias key in cnc_sim_api.cpp: {key}") seen.add(key) PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$keys_csv" <<'JS' const keys = process.argv[2].split(",").filter(Boolean); if (keys.length === 0) { throw new Error("missing switchkins config alias keys in cnc_sim_api.cpp"); } const seen = new Set(); for (const key of keys) { if (key !== key.toLowerCase()) { throw new Error(`switchkins config alias key is not lowercase: ${key}`); } if (/\s/.test(key)) { throw new Error(`switchkins config alias key contains whitespace: ${key}`); } if (seen.has(key)) { throw new Error(`duplicate switchkins config alias key in cnc_sim_api.cpp: ${key}`); } seen.add(key); } JS then exit 1 fi else echo "missing python3 or node for switchkins remap API key validation" >&2 exit 1 fi } validate_api_switchkins_alias_keys "$api_keys_csv" validate_web_switchkins_case_fields() { local json_file=$1 local wasm_core_file=$2 local types_file=$3 local label=$4 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$json_file" "$wasm_core_file" "$types_file" "$label" <<'PY' import json import re import sys json_file, wasm_core_file, types_file, label = sys.argv[1:] with open(json_file, encoding="utf-8") as handle: fields = sorted({entry["field"] for entry in json.load(handle)}) with open(wasm_core_file, encoding="utf-8") as handle: wasm_core = handle.read() with open(types_file, encoding="utf-8") as handle: types = handle.read() missing_wasm = [ field for field in fields if not re.search( rf"\.\.\.\(\s*options\.{re.escape(field)}\s*\?\s*" rf"\{{\s*{re.escape(field)}\s*:\s*options\.{re.escape(field)}\s*\}}\s*:\s*\{{\}}\s*\)", wasm_core, ) ] missing_types = [ field for field in fields if not re.search(rf"\b{re.escape(field)}\?:\s*string\b", types) ] if missing_wasm: raise SystemExit(f"{label} web wasm-core.js does not forward switchkins config field(s): {missing_wasm}") if missing_types: raise SystemExit(f"{label} web TypeScript options do not expose switchkins config field(s): {missing_types}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$json_file" "$wasm_core_file" "$types_file" "$label" <<'JS' const fs = require("fs"); const [jsonFile, wasmCoreFile, typesFile, label] = process.argv.slice(2); const fields = [...new Set(JSON.parse(fs.readFileSync(jsonFile, "utf8")).map((entry) => entry.field))].sort(); const wasmCore = fs.readFileSync(wasmCoreFile, "utf8"); const types = fs.readFileSync(typesFile, "utf8"); const missingWasm = fields.filter((field) => { const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return !new RegExp(`\\.\\.\\.\\(\\s*options\\.${escaped}\\s*\\?\\s*\\{\\s*${escaped}\\s*:\\s*options\\.${escaped}\\s*\\}\\s*:\\s*\\{\\}\\s*\\)`).test(wasmCore); }); const missingTypes = fields.filter((field) => !new RegExp(`\\b${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\?:\\s*string\\b`).test(types)); if (missingWasm.length > 0) { throw new Error(`${label} web wasm-core.js does not forward switchkins config field(s): ${missingWasm.join(",")}`); } if (missingTypes.length > 0) { throw new Error(`${label} web TypeScript options do not expose switchkins config field(s): ${missingTypes.join(",")}`); } JS then exit 1 fi else echo "missing python3 or node for browser switchkins config field validation" >&2 exit 1 fi } validate_tool_table_source_coverage_only() { local table_file=$1 local cpp_file=$2 local json_file=$3 local api_file=$4 local wasm_core_file=$5 local types_file=$6 local label=$7 if command -v python3 >/dev/null 2>&1; then if ! python3 - "$table_file" "$cpp_file" "$json_file" "$api_file" "$wasm_core_file" "$types_file" "$label" <<'PY' import json import re import sys table_file, cpp_file, json_file, api_file, wasm_core_file, types_file, label = sys.argv[1:] table_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) case_row_re = re.compile( r'^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*' r'("(?:(?:\\.)|[^"\\])*"),\s*' r'(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$' ) bad_aliases = [] with open(table_file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = table_row_re.match(line) if match: alias = json.loads(match.group(1)).lower() if alias.endswith(".tbl") or "/tool" in alias or "tool_table" in alias or "tooltable" in alias: bad_aliases.append((line_number, alias)) bad_cases = [] with open(cpp_file, encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): match = case_row_re.match(line) if match: field = json.loads(match.group(1)).lower() value = json.loads(match.group(2)).lower() if "tool" in field or value.endswith(".tbl"): bad_cases.append((line_number, field, value)) with open(json_file, encoding="utf-8") as handle: for index, entry in enumerate(json.load(handle)): field = entry["field"].lower() value = entry["value"].lower() if "tool" in field or value.endswith(".tbl"): bad_cases.append((index, field, value)) with open(api_file, encoding="utf-8") as handle: api_text = handle.read() with open(wasm_core_file, encoding="utf-8") as handle: wasm_text = handle.read() with open(types_file, encoding="utf-8") as handle: types_text = handle.read() bad_bridge_tokens = [] for token, text in { "api": api_text, "wasm": wasm_text, "types": types_text, }.items(): if re.search(r'\btoolTable\b|\btooltable\b|\btool_table\b|\bTOOL_TABLE\b', text): bad_bridge_tokens.append(token) if bad_aliases: raise SystemExit(f"{label} generated TOOL_TABLE alias(es), but TOOL_TABLE is source coverage only: {bad_aliases}") if bad_cases: raise SystemExit(f"{label} generated TOOL_TABLE case(s), but TOOL_TABLE is source coverage only: {bad_cases}") if bad_bridge_tokens: raise SystemExit(f"{label} exposes TOOL_TABLE through switchkins API/web bridge: {bad_bridge_tokens}") PY then exit 1 fi elif command -v node >/dev/null 2>&1; then if ! node - "$table_file" "$cpp_file" "$json_file" "$api_file" "$wasm_core_file" "$types_file" "$label" <<'JS' const fs = require("fs"); const [tableFile, cppFile, jsonFile, apiFile, wasmCoreFile, typesFile, label] = process.argv.slice(2); const tableRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const caseRowRe = /^\s*\{("(?:(?:\\.)|[^"\\])*"),\s*("(?:(?:\\.)|[^"\\])*"),\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)\},?\s*$/; const badAliases = []; fs.readFileSync(tableFile, "utf8").split(/\r?\n/).forEach((line, index) => { const match = tableRowRe.exec(line); if (!match) { return; } const alias = JSON.parse(match[1]).toLowerCase(); if (alias.endsWith(".tbl") || alias.includes("/tool") || alias.includes("tool_table") || alias.includes("tooltable")) { badAliases.push([index + 1, alias]); } }); const badCases = []; fs.readFileSync(cppFile, "utf8").split(/\r?\n/).forEach((line, index) => { const match = caseRowRe.exec(line); if (!match) { return; } const field = JSON.parse(match[1]).toLowerCase(); const value = JSON.parse(match[2]).toLowerCase(); if (field.includes("tool") || value.endsWith(".tbl")) { badCases.push([index + 1, field, value]); } }); JSON.parse(fs.readFileSync(jsonFile, "utf8")).forEach((entry, index) => { const field = entry.field.toLowerCase(); const value = entry.value.toLowerCase(); if (field.includes("tool") || value.endsWith(".tbl")) { badCases.push([index, field, value]); } }); const bridgeTokenRe = /\btoolTable\b|\btooltable\b|\btool_table\b|\bTOOL_TABLE\b/; const badBridgeTokens = [ ["api", fs.readFileSync(apiFile, "utf8")], ["wasm", fs.readFileSync(wasmCoreFile, "utf8")], ["types", fs.readFileSync(typesFile, "utf8")], ].filter(([_name, text]) => bridgeTokenRe.test(text)).map(([name]) => name); if (badAliases.length > 0) { throw new Error(`${label} generated TOOL_TABLE alias(es), but TOOL_TABLE is source coverage only: ${JSON.stringify(badAliases)}`); } if (badCases.length > 0) { throw new Error(`${label} generated TOOL_TABLE case(s), but TOOL_TABLE is source coverage only: ${JSON.stringify(badCases)}`); } if (badBridgeTokens.length > 0) { throw new Error(`${label} exposes TOOL_TABLE through switchkins API/web bridge: ${badBridgeTokens.join(",")}`); } JS then exit 1 fi else echo "missing python3 or node for switchkins TOOL_TABLE source-only validation" >&2 exit 1 fi } for generated_file in \ linuxcnc_switchkins_remap_table.inc \ linuxcnc_switchkins_remap_config_cases.inc \ linuxcnc_switchkins_remap_config_cases.json do if [[ ! -f "$generated_dir/$generated_file" ]]; then echo "missing generated switchkins remap output: $generated_file" >&2 exit 1 fi if [[ ! -s "$generated_dir/$generated_file" ]]; then echo "empty generated switchkins remap output: $generated_file" >&2 exit 1 fi done validate_web_switchkins_case_fields \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ web/src/wasm-core.js \ web/src/index.ts \ "generated" validate_tool_table_source_coverage_only \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ core/src/cnc_sim_api.cpp \ web/src/wasm-core.js \ web/src/index.ts \ "generated" validate_tool_table_source_coverage_only \ core/src/linuxcnc_switchkins_remap_table.inc \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ core/src/cnc_sim_api.cpp \ web/src/wasm-core.js \ web/src/index.ts \ "tracked" validate_generated_header \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "table" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh." validate_generated_header \ core/src/linuxcnc_switchkins_remap_table.inc \ "tracked table" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh." validate_generated_header \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "config cases" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh --config-cases." validate_generated_header \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ "tracked config cases" \ "// Generated by ./generate-linuxcnc-switchkins-remap-table.sh --config-cases." validate_generated_source_notes \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "table" \ "// Source: LinuxCNC INI MACHINE/KINEMATICS/HALFILE/POSTGUI_HALFILE/SUBROUTINE_PATH/REMAP entries and" \ "// adjacent remap_subs/{428,429,430}remap.ngc # assignments." validate_generated_source_notes \ core/src/linuxcnc_switchkins_remap_table.inc \ "tracked table" \ "// Source: LinuxCNC INI MACHINE/KINEMATICS/HALFILE/POSTGUI_HALFILE/SUBROUTINE_PATH/REMAP entries and" \ "// adjacent remap_subs/{428,429,430}remap.ngc # assignments." validate_generated_source_notes \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "config cases" \ "// Source: LinuxCNC INI config path, MACHINE, KINEMATICS, non-LIB HALFILE/POSTGUI_HALFILE," \ "// SUBROUTINE_PATH-resolved remap_subs entries, and adjacent M428/M429/M430 # assignments." validate_generated_source_notes \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ "tracked config cases" \ "// Source: LinuxCNC INI config path, MACHINE, KINEMATICS, non-LIB HALFILE/POSTGUI_HALFILE," \ "// SUBROUTINE_PATH-resolved remap_subs entries, and adjacent M428/M429/M430 # assignments." validate_json "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" "generated linuxcnc_switchkins_remap_config_cases.json" validate_json web/public/linuxcnc_switchkins_remap_config_cases.json "web/public/linuxcnc_switchkins_remap_config_cases.json" validate_cpp_table "$generated_dir/linuxcnc_switchkins_remap_table.inc" "generated" validate_cpp_table core/src/linuxcnc_switchkins_remap_table.inc "tracked" validate_cpp_cases "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" "generated" validate_cpp_cases core/tests/linuxcnc_switchkins_remap_config_cases.inc "tracked" compare_cpp_json_cases \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" compare_cpp_json_cases \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_switchkins_case_fields_against_api \ "$api_keys_csv" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_case_values_against_table \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_manifest_ini_paths_in_outputs \ "$manifest" \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_manifest_remap_paths_in_outputs \ "$manifest" \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_manifest_hal_paths_in_outputs \ "$manifest" \ "$generated_dir/linuxcnc_switchkins_remap_table.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" \ "$generated_dir/linuxcnc_switchkins_remap_config_cases.json" \ "generated" validate_switchkins_case_fields_against_api \ "$api_keys_csv" \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_case_values_against_table \ core/src/linuxcnc_switchkins_remap_table.inc \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_manifest_ini_paths_in_outputs \ "$manifest" \ core/src/linuxcnc_switchkins_remap_table.inc \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_manifest_remap_paths_in_outputs \ "$manifest" \ core/src/linuxcnc_switchkins_remap_table.inc \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" validate_manifest_hal_paths_in_outputs \ "$manifest" \ core/src/linuxcnc_switchkins_remap_table.inc \ core/tests/linuxcnc_switchkins_remap_config_cases.inc \ web/public/linuxcnc_switchkins_remap_config_cases.json \ "tracked" diff -u core/src/linuxcnc_switchkins_remap_table.inc "$generated_dir/linuxcnc_switchkins_remap_table.inc" diff -u core/tests/linuxcnc_switchkins_remap_config_cases.inc "$generated_dir/linuxcnc_switchkins_remap_config_cases.inc" diff -u web/public/linuxcnc_switchkins_remap_config_cases.json "$generated_dir/linuxcnc_switchkins_remap_config_cases.json"