| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- #!/usr/bin/env python3
- # Find config option names in docs that do not match YAML keys from config.go.
- #
- # OliveTin config uses camelCase koanf tags (see service/internal/config/config.go).
- # Docs sometimes use Go struct field names (PascalCase) or other wrong spellings.
- from __future__ import annotations
- from dataclasses import dataclass
- from pathlib import Path
- import re
- import sys
- ROOT = Path(__file__).resolve().parent
- REPO_ROOT = ROOT.parents[2]
- CONFIG_GO = REPO_ROOT / "service/internal/config/config.go"
- DOC_DIRS = (ROOT / "pages", ROOT / "partials")
- STRUCT_START_RE = re.compile(r"^type (\w+) struct\b")
- FIELD_RE = re.compile(
- r'^\s+(\w+)\s+([^`]+?)`koanf:"([^"]+)"`',
- )
- BACKTICK_RE = re.compile(r"`([^`]+)`")
- YAML_BLOCK_RE = re.compile(
- r"\[source,yaml\][^\n]*\n----\n(.*?)\n----",
- re.DOTALL,
- )
- YAML_KEY_RE = re.compile(r"^(\s*)([A-Za-z][\w]*)\s*:", re.MULTILINE)
- SKIP_BACKTICK = frozenset({
- "Insecure*",
- })
- SKIP_YAML_PREFIXES = frozenset({
- "actions",
- "dashboards",
- "entities",
- "title",
- "shell",
- "icon",
- "arguments",
- "name",
- "type",
- "default",
- "description",
- "choices",
- "value",
- "permissions",
- "view",
- "exec",
- "logs",
- "kill",
- "matchUsergroups",
- "matchUsernames",
- "policy",
- "users",
- "username",
- "password",
- "usergroup",
- "enabled",
- "acls",
- "groups",
- "maxConcurrent",
- "timeout",
- "onclick",
- "execOnStartup",
- "maxRate",
- "limit",
- "duration",
- "id",
- "hidden",
- "category",
- "contents",
- "file",
- "properties",
- "inlineAction",
- "resultsDirectory",
- "outputDirectory",
- "directory",
- "showDiagnostics",
- "showLogList",
- "showVersionNumber",
- "headerSearch",
- "defaultGoMetrics",
- "contentSecurityPolicy",
- "xFrameOptions",
- "headerContentSecurityPolicy",
- "headerXContentTypeOptions",
- "headerXFrameOptions",
- "forceSecureCookies",
- "clientId",
- "clientSecret",
- "authUrl",
- "tokenUrl",
- "whoamiUrl",
- "scopes",
- "addToUsergroup",
- "userGroupField",
- "usernameField",
- "certBundlePath",
- "callbackTimeout",
- "insecureSkipVerify",
- "secret",
- "authType",
- "authHeader",
- "matchHeaders",
- "matchPath",
- "matchQuery",
- "extract",
- "template",
- "justification",
- "apiKey",
- "addToEveryAction",
- "execOnCron",
- "execOnCalendarFile",
- "shellAfterCompleted",
- "execOnWebhook",
- "triggers",
- "exec",
- "execOnFileCreatedInDir",
- "execOnFileChangedInDir",
- "entity",
- "popupOnStart",
- "saveLogs",
- "suggestions",
- "suggestionsBrowserKey",
- "rejectNull",
- "queueSize",
- "cssClass",
- "url",
- "target",
- "styleMods",
- "include",
- "bannerCss",
- "bannerMessage",
- "serviceHostMode",
- "themeCacheDisabled",
- "checkForUpdates",
- "logHistoryPageSize",
- "additionalNavigationLinks",
- "actionGroups",
- "authOAuth2Providers",
- "authOAuth2RedirectUrl",
- "authJwtHmacSecret",
- })
- @dataclass(frozen=True)
- class Issue:
- path: str
- line: int
- found: str
- expected: str
- kind: str
- def parse_structs(content: str) -> dict[str, list[tuple[str, str, str]]]:
- structs: dict[str, list[tuple[str, str, str]]] = {}
- current: str | None = None
- for line in content.splitlines():
- struct_match = STRUCT_START_RE.match(line)
- if struct_match:
- current = struct_match.group(1)
- structs[current] = []
- continue
- if current is None:
- continue
- if line.strip() == "}":
- current = None
- continue
- field_match = FIELD_RE.match(line)
- if not field_match:
- continue
- field_name, field_type, koanf_tag = field_match.groups()
- if koanf_tag == "-":
- continue
- structs[current].append((field_name, field_type.strip(), koanf_tag.strip()))
- return structs
- def is_nested_struct(field_type: str, structs: dict[str, list[tuple[str, str, str]]]) -> str | None:
- inner = field_type.removeprefix("[]").removeprefix("*").strip()
- if inner in structs and inner not in {
- "Action",
- "EntityFile",
- "AccessControlList",
- "DashboardComponent",
- "NavigationLink",
- "OAuth2Provider",
- "LocalUser",
- "ActionArgument",
- "ActionArgumentChoice",
- "RateSpec",
- "WebhookConfig",
- "EntityProperty",
- "ActionGroup",
- }:
- return inner
- return None
- def collect_config_keys(
- structs: dict[str, list[tuple[str, str, str]]],
- ) -> tuple[frozenset[str], dict[str, str]]:
- valid: set[str] = set()
- aliases: dict[str, str] = {}
- def walk(type_name: str, prefix: str = "") -> None:
- for field_name, _field_type, koanf_tag in structs.get(type_name, []):
- path = f"{prefix}.{koanf_tag}" if prefix else koanf_tag
- valid.add(path)
- if field_name != koanf_tag:
- aliases[field_name] = koanf_tag
- if prefix:
- aliases[f"{prefix}.{field_name}"] = path
- nested = is_nested_struct(_field_type, structs)
- if nested:
- walk(nested, path)
- walk("Config")
- return frozenset(valid), aliases
- def camelize_path(key: str) -> str:
- parts = []
- for part in key.split("."):
- if part and part[0].isupper():
- parts.append(part[0].lower() + part[1:])
- else:
- parts.append(part)
- return ".".join(parts)
- def looks_like_config_key(key: str) -> bool:
- if not key or key in SKIP_BACKTICK:
- return False
- if "*" in key or " " in key or "/" in key or ":" in key:
- return False
- return bool(re.fullmatch(r"[A-Za-z][\w.]*", key))
- def has_internal_uppercase(key: str) -> bool:
- if "." in key:
- return any(has_internal_uppercase(part) for part in key.split("."))
- return any(char.isupper() for char in key[1:])
- def case_insensitive_match(key: str, valid: frozenset[str]) -> str | None:
- matches = [candidate for candidate in valid if candidate.lower() == key.lower()]
- if len(matches) == 1:
- return matches[0]
- return None
- def resolve_key(
- key: str,
- valid: frozenset[str],
- aliases: dict[str, str],
- *,
- allow_case_insensitive: bool = False,
- ) -> str | None:
- if key in valid:
- return None
- if key in aliases and key != aliases[key]:
- return aliases[key]
- if allow_case_insensitive:
- matched = case_insensitive_match(key, valid)
- if matched is not None and matched != key:
- return matched
- if not has_internal_uppercase(key):
- return None
- camelized = camelize_path(key)
- if camelized in valid and key != camelized:
- return camelized
- return None
- def scan_backticks(
- rel_path: str,
- content: str,
- valid: frozenset[str],
- aliases: dict[str, str],
- ) -> list[Issue]:
- issues: list[Issue] = []
- for line_number, line in enumerate(content.splitlines(), start=1):
- for match in BACKTICK_RE.finditer(line):
- key = match.group(1).strip()
- if not looks_like_config_key(key):
- continue
- expected = resolve_key(key, valid, aliases)
- if expected is None:
- continue
- # Backticks often label UI sections that share a name with config keys.
- if expected == "actions" and key == "Actions":
- continue
- issues.append(
- Issue(
- path=rel_path,
- line=line_number,
- found=key,
- expected=expected,
- kind="backtick",
- )
- )
- return issues
- def scan_yaml_blocks(
- rel_path: str,
- content: str,
- valid: frozenset[str],
- aliases: dict[str, str],
- ) -> list[Issue]:
- issues: list[Issue] = []
- for block in YAML_BLOCK_RE.finditer(content):
- block_text = block.group(1)
- block_start = block.start(1)
- for match in YAML_KEY_RE.finditer(block_text):
- indent = len(match.group(1).replace("\t", " "))
- key = match.group(2)
- if indent != 0:
- continue
- if key in SKIP_YAML_PREFIXES:
- continue
- if key in valid:
- continue
- expected = resolve_key(
- key,
- valid,
- aliases,
- allow_case_insensitive=True,
- )
- if expected is None:
- continue
- issues.append(
- Issue(
- path=rel_path,
- line=content.count("\n", 0, block_start + match.start()) + 1,
- found=key,
- expected=expected,
- kind="yaml",
- )
- )
- return issues
- def iter_doc_files() -> list[Path]:
- files: list[Path] = []
- for doc_dir in DOC_DIRS:
- files.extend(sorted(doc_dir.rglob("*.adoc")))
- return files
- def main() -> int:
- if not CONFIG_GO.is_file():
- print(f"config.go not found: {CONFIG_GO}", file=sys.stderr)
- return 2
- structs = parse_structs(CONFIG_GO.read_text())
- valid, aliases = collect_config_keys(structs)
- issues: list[Issue] = []
- for doc_path in iter_doc_files():
- content = doc_path.read_text()
- rel_path = str(doc_path.relative_to(ROOT))
- issues.extend(scan_backticks(rel_path, content, valid, aliases))
- issues.extend(scan_yaml_blocks(rel_path, content, valid, aliases))
- if not issues:
- print("No config key casing issues found.")
- return 0
- print(f"Issues: {len(issues)}")
- for issue in issues:
- print(
- f"{issue.path}:{issue.line}: [{issue.kind}] "
- f"`{issue.found}` should be `{issue.expected}`"
- )
- return 1
- if __name__ == "__main__":
- sys.exit(main())
|