Implement configuration, runtime, frontend, and task governance closure
This commit is contained in:
145
web/workspace/configuration.mjs
Normal file
145
web/workspace/configuration.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
const CONFIGURATION_SCHEMA = "configuration-startup-closure.v1";
|
||||
|
||||
export const CONFIGURATION_ERRORS = Object.freeze({
|
||||
invalid_reference: "configuration_reference_invalid",
|
||||
empty_reference: "configuration_reference_empty",
|
||||
host_path: "configuration_host_path",
|
||||
outside_repo: "configuration_outside_repo",
|
||||
path_escape: "configuration_path_escape",
|
||||
missing: "configuration_resource_missing",
|
||||
ambiguous: "configuration_resource_ambiguous",
|
||||
unavailable: "configuration_resource_unavailable",
|
||||
});
|
||||
|
||||
export const LIBRARY_ROOTS = Object.freeze([
|
||||
"linuxcnc/lib/hallib",
|
||||
"linuxcnc/lib",
|
||||
]);
|
||||
|
||||
function asPathSet(paths) {
|
||||
if (paths instanceof Set) return paths;
|
||||
if (Array.isArray(paths)) return new Set(paths);
|
||||
if (paths && typeof paths === "object") return new Set(Object.keys(paths));
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, path: null };
|
||||
}
|
||||
if (value.includes("\0")) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, path: null };
|
||||
}
|
||||
const parts = [];
|
||||
for (const part of value.replaceAll("\\", "/").split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") {
|
||||
if (parts.length === 0) return { status: "blocked", code: CONFIGURATION_ERRORS.path_escape, path: null };
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
return { status: "accepted", code: null, path: parts.join("/") };
|
||||
}
|
||||
|
||||
function dirname(path) {
|
||||
const index = path.lastIndexOf("/");
|
||||
return index < 0 ? "" : path.slice(0, index);
|
||||
}
|
||||
|
||||
function joinPath(base, value) {
|
||||
return normalizePath(base ? `${base}/${value}` : value);
|
||||
}
|
||||
|
||||
export function splitReference(rawValue) {
|
||||
if (typeof rawValue !== "string") {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, value: null, args: [] };
|
||||
}
|
||||
const raw = rawValue.trim().replace(/^['"]|['"]$/g, "");
|
||||
if (!raw) return { status: "blocked", code: CONFIGURATION_ERRORS.empty_reference, value: null, args: [] };
|
||||
const tokens = raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
||||
if (tokens.length === 0) return { status: "blocked", code: CONFIGURATION_ERRORS.empty_reference, value: null, args: [] };
|
||||
const value = tokens[0].replace(/^['"]|['"]$/g, "");
|
||||
const args = tokens.slice(1).map((token) => token.replace(/^['"]|['"]$/g, ""));
|
||||
return { status: "accepted", code: null, raw, value, args };
|
||||
}
|
||||
|
||||
export function resolveReference({ rawValue, entrypoint, availablePaths = [], libraryRoots = LIBRARY_ROOTS } = {}) {
|
||||
const parsed = splitReference(rawValue);
|
||||
if (parsed.status !== "accepted") return { ...parsed, path: null, attempted_paths: [] };
|
||||
if (typeof entrypoint !== "string" || entrypoint.length === 0) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
|
||||
}
|
||||
if (parsed.value.startsWith("~") || parsed.value.startsWith("$") || parsed.value.startsWith("${")) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.host_path, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
|
||||
}
|
||||
const available = asPathSet(availablePaths);
|
||||
const entry = normalizePath(entrypoint);
|
||||
if (entry.status !== "accepted") return { ...entry, value: parsed.value, args: parsed.args, attempted_paths: [] };
|
||||
let candidates;
|
||||
let resolution = "relative";
|
||||
if (parsed.value.startsWith("LIB:")) {
|
||||
resolution = "library";
|
||||
const libraryName = parsed.value.slice("LIB:".length);
|
||||
if (!libraryName || libraryName.startsWith("/") || libraryName.includes("..")) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
|
||||
}
|
||||
candidates = libraryRoots.map((root) => normalizePath(`${root}/${libraryName}`)).filter((item) => item.status === "accepted").map((item) => item.path);
|
||||
} else if (parsed.value.startsWith("/")) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.outside_repo, value: parsed.value, args: parsed.args, path: null, attempted_paths: [] };
|
||||
} else if (parsed.value.startsWith("linuxcnc/")) {
|
||||
resolution = "repo_relative";
|
||||
const normalized = normalizePath(parsed.value);
|
||||
if (normalized.status !== "accepted") return { ...normalized, value: parsed.value, args: parsed.args, attempted_paths: [] };
|
||||
candidates = [normalized.path];
|
||||
} else {
|
||||
const normalized = joinPath(dirname(entry.path), parsed.value);
|
||||
if (normalized.status !== "accepted") return { ...normalized, value: parsed.value, args: parsed.args, attempted_paths: [] };
|
||||
if (normalized.path !== "linuxcnc" && !normalized.path.startsWith("linuxcnc/")) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.outside_repo, value: parsed.value, args: parsed.args, path: null, attempted_paths: [normalized.path], resolution };
|
||||
}
|
||||
candidates = [normalized.path];
|
||||
}
|
||||
const existing = candidates.filter((candidate) => available.has(candidate));
|
||||
if (existing.length > 1) {
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.ambiguous, value: parsed.value, args: parsed.args, path: null, attempted_paths: candidates, resolution };
|
||||
}
|
||||
if (existing.length === 1) {
|
||||
return { status: "resolved", code: null, value: parsed.value, args: parsed.args, path: existing[0], attempted_paths: candidates, resolution };
|
||||
}
|
||||
return { status: "blocked", code: CONFIGURATION_ERRORS.missing, value: parsed.value, args: parsed.args, path: null, attempted_paths: candidates, resolution };
|
||||
}
|
||||
|
||||
export function resolveResourceRow(row, availablePaths, options = {}) {
|
||||
if (!row || typeof row !== "object") return { status: "blocked", code: CONFIGURATION_ERRORS.invalid_reference, path: null };
|
||||
const result = resolveReference({
|
||||
rawValue: row.raw_value ?? row.path,
|
||||
entrypoint: row.entrypoint,
|
||||
availablePaths,
|
||||
...options,
|
||||
});
|
||||
return {
|
||||
resource_source_id: row.resource_source_id ?? null,
|
||||
entrypoint: row.entrypoint ?? null,
|
||||
original_path: row.path ?? null,
|
||||
original_status: row.parse_status ?? null,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
export function preflightConfiguration({ entrypoint, rows = [], availablePaths = [], libraryRoots = LIBRARY_ROOTS } = {}) {
|
||||
const resolved = rows.map((row) => resolveResourceRow(row, availablePaths, { libraryRoots }));
|
||||
const blocked = resolved.filter((row) => row.status !== "resolved");
|
||||
return {
|
||||
schema: CONFIGURATION_SCHEMA,
|
||||
entrypoint,
|
||||
status: blocked.length === 0 ? "ready" : "blocked",
|
||||
resource_count: resolved.length,
|
||||
resolved_count: resolved.length - blocked.length,
|
||||
blocked_count: blocked.length,
|
||||
resources: resolved,
|
||||
};
|
||||
}
|
||||
|
||||
export { CONFIGURATION_SCHEMA };
|
||||
49
web/workspace/hal-configuration.mjs
Normal file
49
web/workspace/hal-configuration.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
export const HAL_CONFIGURATION_SCHEMA = "hal-configuration-preflight.v1";
|
||||
|
||||
const BUILTIN_THREADS = new Set(["servo-thread", "base-thread"]);
|
||||
|
||||
function text(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function parseHalLine(raw, line = 0) {
|
||||
if (typeof raw !== "string") return { status: "blocked", code: "hal_line_invalid", line };
|
||||
const clean = raw.split("#", 1)[0].trim();
|
||||
if (!clean) return null;
|
||||
const tokens = clean.split(/\s+/);
|
||||
const command = tokens[0];
|
||||
if (!["loadrt", "loadusr", "addf", "net", "setp", "source", "newsig", "linksp", "linkps", "linkpp"].includes(command)) return null;
|
||||
if (command === "loadrt") {
|
||||
const module = tokens[1] ?? "";
|
||||
const namesToken = tokens.find((token) => token.startsWith("names="));
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, module, instances: namesToken ? namesToken.slice(6).split(",") : [], status: module.startsWith("[") ? "blocked" : "declared", code: module.startsWith("[") ? "hal_component_variable" : null };
|
||||
}
|
||||
if (command === "loadusr") return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: "blocked", code: "hal_host_dynamic_loader", program: tokens[1] ?? "" };
|
||||
if (command === "addf") {
|
||||
const thread = tokens[2] ?? "";
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, function: tokens[1] ?? null, thread, status: text(thread) && BUILTIN_THREADS.has(thread) ? "checked" : "blocked", code: text(thread) && BUILTIN_THREADS.has(thread) ? null : "hal_thread_undeclared" };
|
||||
}
|
||||
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, tokens: tokens.slice(1), status: "checked", code: null };
|
||||
}
|
||||
|
||||
export function preflightHalConfiguration({ lines = [], manifest = [] } = {}) {
|
||||
const declared = new Set((Array.isArray(manifest) ? manifest : []).map((row) => row?.component).filter(Boolean));
|
||||
const records = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const parsed = parseHalLine(lines[index], index + 1);
|
||||
if (!parsed) continue;
|
||||
if (parsed.command === "loadrt" && parsed.status === "declared" && !declared.has(parsed.module)) {
|
||||
parsed.status = "blocked";
|
||||
parsed.code = "hal_component_undeclared";
|
||||
}
|
||||
records.push(parsed);
|
||||
}
|
||||
const blocked = records.filter((record) => record.status === "blocked");
|
||||
return {
|
||||
schema: HAL_CONFIGURATION_SCHEMA,
|
||||
status: blocked.length === 0 ? "ready" : "blocked",
|
||||
records,
|
||||
record_count: records.length,
|
||||
blocked_count: blocked.length,
|
||||
};
|
||||
}
|
||||
34
web/workspace/test_configuration.mjs
Normal file
34
web/workspace/test_configuration.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { preflightConfiguration, resolveReference, splitReference } from "./configuration.mjs";
|
||||
|
||||
assert.deepEqual(splitReference("LIB:basic_sim.tcl -no_sim_spindle"), {
|
||||
status: "accepted",
|
||||
code: null,
|
||||
raw: "LIB:basic_sim.tcl -no_sim_spindle",
|
||||
value: "LIB:basic_sim.tcl",
|
||||
args: ["-no_sim_spindle"],
|
||||
});
|
||||
|
||||
const available = new Set([
|
||||
"linuxcnc/lib/hallib/basic_sim.tcl",
|
||||
"linuxcnc/configs/sim/demo/core.hal",
|
||||
"linuxcnc/nc_files/probe.ngc",
|
||||
]);
|
||||
assert.equal(resolveReference({ rawValue: "LIB:basic_sim.tcl -no_sim_spindle", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).path, "linuxcnc/lib/hallib/basic_sim.tcl");
|
||||
assert.equal(resolveReference({ rawValue: "core.hal", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).path, "linuxcnc/configs/sim/demo/core.hal");
|
||||
assert.equal(resolveReference({ rawValue: "linuxcnc/nc_files/probe.ngc", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).path, "linuxcnc/nc_files/probe.ngc");
|
||||
assert.equal(resolveReference({ rawValue: "~/linuxcnc/nc_files", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).code, "configuration_host_path");
|
||||
assert.equal(resolveReference({ rawValue: "../../../../etc/passwd", entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: available }).code, "configuration_outside_repo");
|
||||
const report = preflightConfiguration({
|
||||
entrypoint: "linuxcnc/configs/sim/demo/main.ini",
|
||||
availablePaths: available,
|
||||
rows: [
|
||||
{ resource_source_id: "r1", entrypoint: "linuxcnc/configs/sim/demo/main.ini", path: "LIB:basic_sim.tcl -no_sim_spindle", raw_value: "LIB:basic_sim.tcl -no_sim_spindle", parse_status: "missing" },
|
||||
{ resource_source_id: "r2", entrypoint: "linuxcnc/configs/sim/demo/main.ini", path: "missing.hal", raw_value: "missing.hal", parse_status: "missing" },
|
||||
],
|
||||
});
|
||||
assert.equal(report.schema, "configuration-startup-closure.v1");
|
||||
assert.equal(report.status, "blocked");
|
||||
assert.equal(report.resolved_count, 1);
|
||||
assert.equal(report.blocked_count, 1);
|
||||
console.log(`configuration_resolver=passed resolved=${report.resolved_count} blocked=${report.blocked_count}`);
|
||||
12
web/workspace/test_hal_configuration.mjs
Normal file
12
web/workspace/test_hal_configuration.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { preflightHalConfiguration } from "./hal-configuration.mjs";
|
||||
|
||||
const report = preflightHalConfiguration({
|
||||
manifest: [{ component: "demo" }],
|
||||
lines: ["loadrt demo names=d0", "addf d0 servo-thread", "loadusr -W external"],
|
||||
});
|
||||
assert.equal(report.schema, "hal-configuration-preflight.v1");
|
||||
assert.equal(report.status, "blocked");
|
||||
assert.equal(report.blocked_count, 1);
|
||||
assert.equal(report.records[2].code, "hal_host_dynamic_loader");
|
||||
console.log(`hal_preflight=passed records=${report.record_count} blocked=${report.blocked_count}`);
|
||||
Reference in New Issue
Block a user