Let the conf say which control each setting deserves

The comments above a setting already state its choices, units and bounds, so the
form can read them instead of asking for the file to be annotated first.
This commit is contained in:
Gmer4Lfe
2026-08-11 18:17:04 -04:00
parent 76ce424f8f
commit a95def9139
2 changed files with 347 additions and 3 deletions
+210
View File
@@ -0,0 +1,210 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Checks how the settings UI will draw every conf field, and proves the inference rules against
// the conventions the conf files actually use. Two modes in one tool: fixed assertions over
// synthetic snippets, and a report of what this host's real master.conf infers to.
//
// OPERATIONAL MODEL
// Hand-run, reads only, writes nothing — the same shape as Tools/ai_log_check.sh. Run it after
// touching _vv_conf_widget(), after adding a conf convention, or when a setting draws as the
// wrong control and the question is whether the rule or the comment above it is at fault.
//
// php Tools/conf_widget_check.php assertions, then the live summary
// php Tools/conf_widget_check.php --list every live field and its inferred control
//
// WHY IT ASSERTS AGAINST SNIPPETS AND NOT THE LIVE CONF
// The live conf is the thing being described, so it cannot also be the thing that proves the
// description right — an inference rule that silently stopped matching would keep passing as
// the conf drifted to suit it. The snippets are frozen copies of each convention as written,
// so a rule change that breaks one shows up here rather than as a wrong control on a page.
//
// WHAT AN INFERENCE IS NOT
// Consistent with confform.php, none of this validates. A number field carrying min and max is
// a courtesy to whoever is typing, not a promise the value is sensible — the consuming script
// still owns that question.
//
// DEPENDS ON
// include/confform.php _vv_conf_parse_field_range(), vv_conf_key_is_secret()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/../include/confform.php';
$pass = 0; $fail = 0; $failed = [];
function check(string $name, $got, $want): void {
global $pass, $fail, $failed;
if ($got === $want) { $pass++; return; }
$fail++;
$failed[] = sprintf("%-42s got %s want %s", $name, json_encode($got), json_encode($want));
}
// Parse one snippet and return its fields keyed by name.
function vv_wc_fields(string $snippet): array {
$lines = explode("\n", $snippet);
$out = [];
foreach (_vv_conf_parse_field_range($lines, 0, count($lines), 'master.conf') as $f) {
$out[$f['key']] = $f;
}
return $out;
}
// ── Booleans and numbers ─────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
AI_ENABLED=true
AI_OFF=false
AI_QUOTED_BOOL="true"
AI_CONNECT_TIMEOUT=5 # seconds — probe when resolving which node has Ollama
AI_CHAT_HISTORY_MAX=10 # conversations kept — clamped to 1-50
AI_PLAIN_NUMBER=32
AI_NEGATIVE=-1
CONF);
check('bool true', $f['AI_ENABLED']['widget'], 'bool');
check('bool false', $f['AI_OFF']['widget'], 'bool');
check('bool quoted', $f['AI_QUOTED_BOOL']['widget'], 'bool');
check('int', $f['AI_CONNECT_TIMEOUT']['widget'], 'int');
check('int unit', $f['AI_CONNECT_TIMEOUT']['unit'] ?? null, 'seconds');
check('int without a unit', $f['AI_PLAIN_NUMBER']['unit'] ?? null, null);
check('int range min', $f['AI_CHAT_HISTORY_MAX']['min'] ?? null, 1);
check('int range max', $f['AI_CHAT_HISTORY_MAX']['max'] ?? null, 50);
check('int range keeps unit', $f['AI_CHAT_HISTORY_MAX']['unit'] ?? null, 'conversations');
check('negative int', $f['AI_NEGATIVE']['widget'], 'int');
// ── File modes must never become spinners ────────────────────────────────────────────────────
// A spinner invites arrowing 755 to 756, and a number input normalises a leading zero away.
$f = vv_wc_fields(<<<'CONF'
PERMISSIONS_DIR_MODE="755" # directories
PERMISSIONS_FILE_MODE="664" # files
TRANSCODE_CHMOD="755"
LEADING_ZERO_MODE="0755"
TRANSCODE_MANAGER_MODE="smart"
SOME_TIMEOUT_MODE=30 # seconds
CONF);
check('dir mode is text', $f['PERMISSIONS_DIR_MODE']['widget'], 'text');
check('file mode is text', $f['PERMISSIONS_FILE_MODE']['widget'], 'text');
check('chmod is text', $f['TRANSCODE_CHMOD']['widget'], 'text');
check('octal mode is text', $f['LEADING_ZERO_MODE']['widget'], 'text');
check('worded mode is text', $f['TRANSCODE_MANAGER_MODE']['widget'], 'text');
// The guard is about octal, not about the word MODE — a real duration is still a number.
check('non-octal MODE is int', $f['SOME_TIMEOUT_MODE']['widget'], 'int');
// ── Enum, quoted-line convention ─────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
# Controls behaviour when versions differ.
# "warn" — log warning and continue
# "abort" — refuse to continue (strict)
UNRAID_VERSION_MISMATCH_ACTION="warn"
CONF);
check('quoted-line enum', $f['UNRAID_VERSION_MISMATCH_ACTION']['widget'], 'enum');
check('enum choices', array_column($f['UNRAID_VERSION_MISMATCH_ACTION']['choices'], 'value'),
['warn', 'abort']);
check('enum keeps its hints', $f['UNRAID_VERSION_MISMATCH_ACTION']['choices'][1]['hint'],
'refuse to continue (strict)');
// One quoted comment line is a quotation, not a choice list of one.
$f = vv_wc_fields("# \"off\" means something here\n SOMETHING=\"off\"");
check('lone quoted line', $f['SOMETHING']['widget'], 'text');
// A value outside its own documented list is surfaced, never dropped to tidy the list — dropping
// it would change the setting the moment the form saved.
$f = vv_wc_fields(<<<'CONF'
# "warn" — log and continue
# "abort" — refuse
DRIFTED="something_else"
CONF);
check('drifted value stays enum', $f['DRIFTED']['widget'], 'enum');
check('drifted value offered', $f['DRIFTED']['choices'][0]['value'], 'something_else');
check('drifted list intact', count($f['DRIFTED']['choices']), 3);
// ── Enum, inline pipe convention ─────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
DIGEST_PROFILE="weekly" # always | smart | weekly
SONARR_MONITOR="all" # monitor mode on add: all | future | first | none
PROSE_WITH_PIPE="x" # pipe the output through grep | sort
CONF);
check('inline enum', $f['DIGEST_PROFILE']['widget'], 'enum');
check('inline enum choices', array_column($f['DIGEST_PROFILE']['choices'], 'value'),
['always', 'smart', 'weekly']);
check('inline enum after colon',$f['SONARR_MONITOR']['widget'], 'enum');
// The value is not among them, so this is prose that happens to contain a pipe.
check('prose with a pipe', $f['PROSE_WITH_PIPE']['widget'], 'text');
// ── Secrets ──────────────────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
HOST1_NPM_PASS="hunter2"
HOST1_SONARR_API_KEY="abcdef"
WEBHOOK_SECRET="s3cr3t"
AI_TOKEN_DB="${AI_DATA_DIR}/ai_token_history.db"
AI_TOKEN_RETAIN_ROWS=20000
AI_TOKEN_SYNC_ENABLED=true
CONF);
check('password masked', $f['HOST1_NPM_PASS']['widget'], 'secret');
check('api key masked', $f['HOST1_SONARR_API_KEY']['widget'], 'secret');
check('secret masked', $f['WEBHOOK_SECRET']['widget'], 'secret');
// LLM token accounting is not a credential. Masking these was the bug this exception fixes.
check('AI_TOKEN_DB visible', $f['AI_TOKEN_DB']['widget'], 'path');
check('AI_TOKEN_RETAIN visible', $f['AI_TOKEN_RETAIN_ROWS']['widget'], 'int');
check('AI_TOKEN_SYNC visible', $f['AI_TOKEN_SYNC_ENABLED']['widget'], 'bool');
// ── Paths, text, arrays ──────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
AI_INDEX_DB="${AI_DATA_DIR}/ai_index.db"
SOME_ABS="/mnt/user/appdata"
AI_MODEL="qwen3:14b"
AI_PROFILES="*"
AI_CONF_WRITE_KEYS=()
LIST_MULTI=(
one
two
)
CONF);
check('templated path', $f['AI_INDEX_DB']['widget'], 'path');
check('absolute path', $f['SOME_ABS']['widget'], 'path');
check('plain text', $f['AI_MODEL']['widget'], 'text');
check('wildcard text', $f['AI_PROFILES']['widget'], 'text');
check('single-line array', $f['AI_CONF_WRITE_KEYS']['widget'], 'lines');
check('multi-line array', $f['LIST_MULTI']['widget'], 'lines');
// The scratch field the parser uses to reach the comment lines must not ride out to the browser.
check('no _lines leak', isset($f['AI_MODEL']['_lines']), false);
printf("assertions: %d passed, %d failed\n", $pass, $fail);
foreach ($failed as $l) echo " FAIL $l\n";
// ── What this host's conf actually infers to ─────────────────────────────────────────────────
$path = CONF_DIR . '/master.conf';
if (!is_readable($path)) {
echo "\nmaster.conf not readable at $path — skipping the live summary\n";
exit($fail ? 1 : 0);
}
$lines = explode("\n", (string) file_get_contents($path));
$hdrs = [];
foreach ($lines as $i => $l) {
if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $l, $m)) $hdrs[] = [$i, trim($m[1])];
}
$list = in_array('--list', $argv, true);
$tally = []; $n = 0; $enums = []; $ranges = [];
foreach ($hdrs as $k => [$i, $name]) {
$end = $hdrs[$k + 1][0] ?? count($lines);
$rows = _vv_conf_parse_field_range($lines, $i + 1, $end, 'master.conf');
if ($list && $rows) printf("\n── %s\n", $name);
foreach ($rows as $x) {
$n++;
$w = $x['widget'] ?? '?';
$tally[$w] = ($tally[$w] ?? 0) + 1;
if ($w === 'enum') $enums[] = $x['key'] . ' = ' . implode(' | ', array_column($x['choices'], 'value'));
if (isset($x['min'])) $ranges[] = $x['key'] . ' ' . $x['min'] . '-' . $x['max'];
if ($list) {
$e = [];
if (isset($x['unit'])) $e[] = $x['unit'];
if (isset($x['min'])) $e[] = $x['min'] . '-' . $x['max'];
printf(" %-34s %-7s %s\n", $x['key'], $w, $e ? '[' . implode(' ', $e) . ']' : '');
}
}
}
printf("\nlive master.conf: %d fields across %d sections\n", $n, count($hdrs));
ksort($tally);
foreach ($tally as $k => $v) printf(" %-8s %d\n", $k, $v);
if ($enums) { echo "\nchoice lists found:\n"; foreach ($enums as $e) echo " $e\n"; }
if ($ranges) { echo "\nbounded numbers found:\n"; foreach ($ranges as $e) echo " $e\n"; }
exit($fail ? 1 : 0);
+137 -3
View File
@@ -234,6 +234,112 @@ function vv_conf_parse_subsection(string $raw, string $subName, string $filename
return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null; return _vv_conf_parse_field_range($lines, $start, $end, $filename) ?: null;
} }
// Which control a field should be drawn with. Derived, never stored — the conf file stays the
// only schema, so this reads what is already written there rather than asking for the file to be
// annotated first. A key added tomorrow gets the right control with no code change and no markup.
//
// The order matters: an explicit statement in the comment beats a guess from the value, because
// the guess cannot tell "false" the boolean from "false" the string a script compares against.
//
// Nothing here validates. Consistent with the rest of this file, it decides shape and leaves
// correctness to the consuming script — a number field with min/max is a courtesy to whoever is
// typing, not a promise that the value is sensible.
function _vv_conf_widget(string $key, string $value, string $type, string $desc, array $descLines): array {
// Arrays get the block editor whatever they contain. One value per line is already the right
// control for a list, and there is no second guess worth making.
if ($type !== 'scalar') return ['widget' => 'lines'];
// Secrets never render in the clear, whatever their value looks like. Reuses the same rule
// the audit log redacts by, so a key cannot be a secret in the log and plain text in a form.
if (vv_conf_key_is_secret($key)) return ['widget' => 'secret'];
// The convention already in the confs: each choice on its own comment line, quoted, then a
// dash and what it does. It was written as documentation and happens to be a complete enum
// declaration, which is exactly the property this file is built around.
//
// # "warn" — log warning and continue
// # "abort" — refuse to continue (strict)
// UNRAID_VERSION_MISMATCH_ACTION="warn"
$choices = [];
foreach ($descLines as $line) {
if (preg_match('/^"([^"]+)"\s*(?:[—–-]\s*(.*))?$/u', trim($line), $m)) {
$choices[] = ['value' => $m[1], 'hint' => trim($m[2] ?? '')];
}
}
// A single quoted line is a quotation, not a choice list.
if (count($choices) > 1) {
// A value outside its own choice list is a misconfiguration, and dropping it to make the
// list tidy would change the setting the moment the form saved. It is offered as a choice
// instead, so the dropdown shows what is actually set and can also correct it.
$have = array_column($choices, 'value');
if (!in_array($value, $have, true)) {
array_unshift($choices, ['value' => $value, 'hint' => 'current value — not one of the documented options']);
}
return ['widget' => 'enum', 'choices' => $choices];
}
// The other convention already in the confs: the choices listed inline after the value, either
// as the whole comment or after a colon.
//
// DIGEST_PROFILE="weekly" # always | smart | weekly
// SONARR_DISCOVERY_MONITOR_MODE="all" # monitor mode on add: all | future | first | none
//
// Anchored to the end of the comment and required to contain the current value. The pattern is
// loose enough to match ordinary prose containing a pipe, and "the value is one of these" is
// the only signal that separates a choice list from a sentence.
if (preg_match('/(?:^|:\s*)([A-Za-z0-9_.\-]+(?:\s*\|\s*[A-Za-z0-9_.\-]+)+)\s*$/', trim($desc), $m)) {
$opts = array_map('trim', explode('|', $m[1]));
if (count($opts) > 1 && in_array($value, $opts, true)) {
return ['widget' => 'enum',
'choices' => array_map(fn($o) => ['value' => $o, 'hint' => ''], $opts)];
}
}
// The escape hatch, for a setting whose options are not already spelled out above it. Written
// to read as English so adding one does not turn the conf into a config language.
if (preg_match('/\bone of:\s*([^.;#]+)/i', $desc, $m)) {
$opts = array_values(array_filter(array_map('trim', preg_split('/\s*[,|]\s*/', $m[1])), 'strlen'));
if (count($opts) > 1) {
return ['widget' => 'enum',
'choices' => array_map(fn($o) => ['value' => trim($o, '"\''), 'hint' => ''], $opts)];
}
}
$v = strtolower(trim($value));
if ($v === 'true' || $v === 'false') return ['widget' => 'bool'];
// A file mode is digits and is not a number. A spinner invites arrowing 755 to 756, and a
// number input normalises a leading zero away — so 0755 would save as 755, which is the same
// mode today and a different one the day anything reads it as octal. Kept as plain text.
if (preg_match('/(CHMOD|_MODE|PERM|UMASK)/', $key) && preg_match('/^0?[0-7]{3}$/', trim($value))) {
return ['widget' => 'text'];
}
if (preg_match('/^-?\d+$/', trim($value))) {
$out = ['widget' => 'int'];
// "clamped to 1-50", "between 1 and 50". Already written in several confs, so the range
// that documents the setting also bounds the field.
if (preg_match('/(?:clamped to|between)\s*(-?\d+)\s*(?:-|to|and)\s*(-?\d+)/i', $desc, $r)) {
$out['min'] = (int) $r[1];
$out['max'] = (int) $r[2];
}
// The house style opens an inline comment with the unit — "# seconds — probe when
// resolving which node has Ollama" — so the unit is already there to be shown beside
// the box instead of buried in the description.
if (preg_match('/^\s*(seconds?|minutes?|hours?|days?|weeks?|months?|percent|%|[KMGT]B|'
. 'chars?|characters?|tokens?|conversations?|entries|items|lines|runs|'
. 'attempts|retries|consecutive)\b/i', $desc, $u)) {
$out['unit'] = strtolower($u[1]);
}
return $out;
}
// A path gets a wider box and no monospace surprises. ${VAR}/... is the established idiom.
if (str_starts_with($value, '/') || str_contains($value, '${')) return ['widget' => 'path'];
return ['widget' => 'text'];
}
// Parse all config fields between two line indices. Shared by vv_conf_parse_subsection() // Parse all config fields between two line indices. Shared by vv_conf_parse_subsection()
// (per-script editor) and vv_conf_all_groups() (full settings view). // (per-script editor) and vv_conf_all_groups() (full settings view).
function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array { function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $filename): array {
@@ -255,6 +361,10 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $
} }
$desc = implode(' ', $pendingDesc); $desc = implode(' ', $pendingDesc);
// Kept as lines as well as joined. The enum convention lives in the line breaks — one
// quoted choice per comment line — and joining them destroys the only thing that
// distinguishes a choice list from a paragraph that happens to contain quotes.
$descLines = $pendingDesc;
$pendingDesc = []; $pendingDesc = [];
// declare -A KEY=( // declare -A KEY=(
@@ -297,17 +407,30 @@ function _vv_conf_parse_field_range(array $lines, int $start, int $end, string $
// Scalar: KEY="value" or KEY=value // Scalar: KEY="value" or KEY=value
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) { if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*"([^"]*)"(?:\s+#\s*(.+))?$/', $line, $m)) {
$fields[] = ['key' => $m[1], 'value' => $m[2], $fields[] = ['key' => $m[1], 'value' => $m[2],
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename]; 'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
'_lines' => $descLines];
continue; continue;
} }
if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) { if (preg_match('/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*([^(\n#]*?)(?:\s+#\s*(.+))?$/', $line, $m)) {
$val = trim($m[2]); $val = trim($m[2]);
if ($val === '') continue; if ($val === '') continue;
$fields[] = ['key' => $m[1], 'value' => $val, $fields[] = ['key' => $m[1], 'value' => $val,
'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename]; 'type' => 'scalar', 'desc' => ($m[3] ?? '') ?: $desc, 'file' => $filename,
'_lines' => $descLines];
} }
} }
// Widget is added in one pass rather than at each of the five construction sites above, so
// every field type goes through the same rules and a new type cannot quietly skip them.
foreach ($fields as &$f) {
$f += _vv_conf_widget((string) $f['key'], (string) $f['value'], (string) $f['type'],
(string) $f['desc'], (array) ($f['_lines'] ?? []));
// Internal to this pass. Left in, it would ride out to the browser on every field as a
// second, subtly different copy of the description.
unset($f['_lines']);
}
unset($f);
return $fields; return $fields;
} }
@@ -367,8 +490,19 @@ function vv_conf_key_valid(string $key): bool {
} }
// Credential-shaped keys are logged by name only. The audit log is the record that a change // Credential-shaped keys are logged by name only. The audit log is the record that a change
// happened, not a second copy of the secret that changed. // happened, not a second copy of the secret that changed. Also decides which fields render
// masked, so the same key cannot be redacted in the log and legible in a form.
//
// The match is deliberately loose — the second alternative is unanchored, so anything merely
// CONTAINING "token" or "pass" counts. Over-redacting a setting costs a little readability;
// under-redacting one puts a credential in a log file, so the bias is correct and stays.
//
// The exception exists because one family trips it on a word that means something else entirely:
// AI_TOKEN_* is LLM token accounting — a path, a row cap, a switch — and nothing there is a
// credential. Named as a literal prefix rather than by relaxing the rule, so this cannot widen
// into "token sometimes means tokens" and quietly expose a real one.
function vv_conf_key_is_secret(string $key): bool { function vv_conf_key_is_secret(string $key): bool {
if (str_starts_with($key, 'AI_TOKEN_')) return false;
return (bool) preg_match('/(PASS|PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|_KEY)$|(PASS|PASSWORD|SECRET|TOKEN|APIKEY)/i', $key); return (bool) preg_match('/(PASS|PASSWORD|SECRET|TOKEN|API_KEY|APIKEY|_KEY)$|(PASS|PASSWORD|SECRET|TOKEN|APIKEY)/i', $key);
} }