check_config_keys.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. #!/usr/bin/env python3
  2. # Find config option names in docs that do not match YAML keys from config.go.
  3. #
  4. # OliveTin config uses camelCase koanf tags (see service/internal/config/config.go).
  5. # Docs sometimes use Go struct field names (PascalCase) or other wrong spellings.
  6. from __future__ import annotations
  7. from dataclasses import dataclass
  8. from pathlib import Path
  9. import re
  10. import sys
  11. ROOT = Path(__file__).resolve().parent
  12. REPO_ROOT = ROOT.parents[2]
  13. CONFIG_GO = REPO_ROOT / "service/internal/config/config.go"
  14. DOC_DIRS = (ROOT / "pages", ROOT / "partials")
  15. STRUCT_START_RE = re.compile(r"^type (\w+) struct\b")
  16. FIELD_RE = re.compile(
  17. r'^\s+(\w+)\s+([^`]+?)`koanf:"([^"]+)"`',
  18. )
  19. BACKTICK_RE = re.compile(r"`([^`]+)`")
  20. YAML_BLOCK_RE = re.compile(
  21. r"\[source,yaml\][^\n]*\n----\n(.*?)\n----",
  22. re.DOTALL,
  23. )
  24. YAML_KEY_RE = re.compile(r"^(\s*)([A-Za-z][\w]*)\s*:", re.MULTILINE)
  25. SKIP_BACKTICK = frozenset({
  26. "Insecure*",
  27. })
  28. SKIP_YAML_PREFIXES = frozenset({
  29. "actions",
  30. "dashboards",
  31. "entities",
  32. "title",
  33. "shell",
  34. "icon",
  35. "arguments",
  36. "name",
  37. "type",
  38. "default",
  39. "description",
  40. "choices",
  41. "value",
  42. "permissions",
  43. "view",
  44. "exec",
  45. "logs",
  46. "kill",
  47. "matchUsergroups",
  48. "matchUsernames",
  49. "policy",
  50. "users",
  51. "username",
  52. "password",
  53. "usergroup",
  54. "enabled",
  55. "acls",
  56. "groups",
  57. "maxConcurrent",
  58. "timeout",
  59. "onclick",
  60. "execOnStartup",
  61. "maxRate",
  62. "limit",
  63. "duration",
  64. "id",
  65. "hidden",
  66. "category",
  67. "contents",
  68. "file",
  69. "properties",
  70. "inlineAction",
  71. "resultsDirectory",
  72. "outputDirectory",
  73. "directory",
  74. "showDiagnostics",
  75. "showLogList",
  76. "showVersionNumber",
  77. "headerSearch",
  78. "defaultGoMetrics",
  79. "contentSecurityPolicy",
  80. "xFrameOptions",
  81. "headerContentSecurityPolicy",
  82. "headerXContentTypeOptions",
  83. "headerXFrameOptions",
  84. "forceSecureCookies",
  85. "clientId",
  86. "clientSecret",
  87. "authUrl",
  88. "tokenUrl",
  89. "whoamiUrl",
  90. "scopes",
  91. "addToUsergroup",
  92. "userGroupField",
  93. "usernameField",
  94. "certBundlePath",
  95. "callbackTimeout",
  96. "insecureSkipVerify",
  97. "secret",
  98. "authType",
  99. "authHeader",
  100. "matchHeaders",
  101. "matchPath",
  102. "matchQuery",
  103. "extract",
  104. "template",
  105. "justification",
  106. "apiKey",
  107. "addToEveryAction",
  108. "execOnCron",
  109. "execOnCalendarFile",
  110. "shellAfterCompleted",
  111. "execOnWebhook",
  112. "triggers",
  113. "exec",
  114. "execOnFileCreatedInDir",
  115. "execOnFileChangedInDir",
  116. "entity",
  117. "popupOnStart",
  118. "saveLogs",
  119. "suggestions",
  120. "suggestionsBrowserKey",
  121. "rejectNull",
  122. "queueSize",
  123. "cssClass",
  124. "url",
  125. "target",
  126. "styleMods",
  127. "include",
  128. "bannerCss",
  129. "bannerMessage",
  130. "serviceHostMode",
  131. "themeCacheDisabled",
  132. "checkForUpdates",
  133. "logHistoryPageSize",
  134. "additionalNavigationLinks",
  135. "actionGroups",
  136. "authOAuth2Providers",
  137. "authOAuth2RedirectUrl",
  138. "authJwtHmacSecret",
  139. })
  140. @dataclass(frozen=True)
  141. class Issue:
  142. path: str
  143. line: int
  144. found: str
  145. expected: str
  146. kind: str
  147. def parse_structs(content: str) -> dict[str, list[tuple[str, str, str]]]:
  148. structs: dict[str, list[tuple[str, str, str]]] = {}
  149. current: str | None = None
  150. for line in content.splitlines():
  151. struct_match = STRUCT_START_RE.match(line)
  152. if struct_match:
  153. current = struct_match.group(1)
  154. structs[current] = []
  155. continue
  156. if current is None:
  157. continue
  158. if line.strip() == "}":
  159. current = None
  160. continue
  161. field_match = FIELD_RE.match(line)
  162. if not field_match:
  163. continue
  164. field_name, field_type, koanf_tag = field_match.groups()
  165. if koanf_tag == "-":
  166. continue
  167. structs[current].append((field_name, field_type.strip(), koanf_tag.strip()))
  168. return structs
  169. def is_nested_struct(field_type: str, structs: dict[str, list[tuple[str, str, str]]]) -> str | None:
  170. inner = field_type.removeprefix("[]").removeprefix("*").strip()
  171. if inner in structs and inner not in {
  172. "Action",
  173. "EntityFile",
  174. "AccessControlList",
  175. "DashboardComponent",
  176. "NavigationLink",
  177. "OAuth2Provider",
  178. "LocalUser",
  179. "ActionArgument",
  180. "ActionArgumentChoice",
  181. "RateSpec",
  182. "WebhookConfig",
  183. "EntityProperty",
  184. "ActionGroup",
  185. }:
  186. return inner
  187. return None
  188. def collect_config_keys(
  189. structs: dict[str, list[tuple[str, str, str]]],
  190. ) -> tuple[frozenset[str], dict[str, str]]:
  191. valid: set[str] = set()
  192. aliases: dict[str, str] = {}
  193. def walk(type_name: str, prefix: str = "") -> None:
  194. for field_name, _field_type, koanf_tag in structs.get(type_name, []):
  195. path = f"{prefix}.{koanf_tag}" if prefix else koanf_tag
  196. valid.add(path)
  197. if field_name != koanf_tag:
  198. aliases[field_name] = koanf_tag
  199. if prefix:
  200. aliases[f"{prefix}.{field_name}"] = path
  201. nested = is_nested_struct(_field_type, structs)
  202. if nested:
  203. walk(nested, path)
  204. walk("Config")
  205. return frozenset(valid), aliases
  206. def camelize_path(key: str) -> str:
  207. parts = []
  208. for part in key.split("."):
  209. if part and part[0].isupper():
  210. parts.append(part[0].lower() + part[1:])
  211. else:
  212. parts.append(part)
  213. return ".".join(parts)
  214. def looks_like_config_key(key: str) -> bool:
  215. if not key or key in SKIP_BACKTICK:
  216. return False
  217. if "*" in key or " " in key or "/" in key or ":" in key:
  218. return False
  219. return bool(re.fullmatch(r"[A-Za-z][\w.]*", key))
  220. def has_internal_uppercase(key: str) -> bool:
  221. if "." in key:
  222. return any(has_internal_uppercase(part) for part in key.split("."))
  223. return any(char.isupper() for char in key[1:])
  224. def case_insensitive_match(key: str, valid: frozenset[str]) -> str | None:
  225. matches = [candidate for candidate in valid if candidate.lower() == key.lower()]
  226. if len(matches) == 1:
  227. return matches[0]
  228. return None
  229. def resolve_key(
  230. key: str,
  231. valid: frozenset[str],
  232. aliases: dict[str, str],
  233. *,
  234. allow_case_insensitive: bool = False,
  235. ) -> str | None:
  236. if key in valid:
  237. return None
  238. if key in aliases and key != aliases[key]:
  239. return aliases[key]
  240. if allow_case_insensitive:
  241. matched = case_insensitive_match(key, valid)
  242. if matched is not None and matched != key:
  243. return matched
  244. if not has_internal_uppercase(key):
  245. return None
  246. camelized = camelize_path(key)
  247. if camelized in valid and key != camelized:
  248. return camelized
  249. return None
  250. def scan_backticks(
  251. rel_path: str,
  252. content: str,
  253. valid: frozenset[str],
  254. aliases: dict[str, str],
  255. ) -> list[Issue]:
  256. issues: list[Issue] = []
  257. for line_number, line in enumerate(content.splitlines(), start=1):
  258. for match in BACKTICK_RE.finditer(line):
  259. key = match.group(1).strip()
  260. if not looks_like_config_key(key):
  261. continue
  262. expected = resolve_key(key, valid, aliases)
  263. if expected is None:
  264. continue
  265. # Backticks often label UI sections that share a name with config keys.
  266. if expected == "actions" and key == "Actions":
  267. continue
  268. issues.append(
  269. Issue(
  270. path=rel_path,
  271. line=line_number,
  272. found=key,
  273. expected=expected,
  274. kind="backtick",
  275. )
  276. )
  277. return issues
  278. def scan_yaml_blocks(
  279. rel_path: str,
  280. content: str,
  281. valid: frozenset[str],
  282. aliases: dict[str, str],
  283. ) -> list[Issue]:
  284. issues: list[Issue] = []
  285. for block in YAML_BLOCK_RE.finditer(content):
  286. block_text = block.group(1)
  287. block_start = block.start(1)
  288. for match in YAML_KEY_RE.finditer(block_text):
  289. indent = len(match.group(1).replace("\t", " "))
  290. key = match.group(2)
  291. if indent != 0:
  292. continue
  293. if key in SKIP_YAML_PREFIXES:
  294. continue
  295. if key in valid:
  296. continue
  297. expected = resolve_key(
  298. key,
  299. valid,
  300. aliases,
  301. allow_case_insensitive=True,
  302. )
  303. if expected is None:
  304. continue
  305. issues.append(
  306. Issue(
  307. path=rel_path,
  308. line=content.count("\n", 0, block_start + match.start()) + 1,
  309. found=key,
  310. expected=expected,
  311. kind="yaml",
  312. )
  313. )
  314. return issues
  315. def iter_doc_files() -> list[Path]:
  316. files: list[Path] = []
  317. for doc_dir in DOC_DIRS:
  318. files.extend(sorted(doc_dir.rglob("*.adoc")))
  319. return files
  320. def main() -> int:
  321. if not CONFIG_GO.is_file():
  322. print(f"config.go not found: {CONFIG_GO}", file=sys.stderr)
  323. return 2
  324. structs = parse_structs(CONFIG_GO.read_text())
  325. valid, aliases = collect_config_keys(structs)
  326. issues: list[Issue] = []
  327. for doc_path in iter_doc_files():
  328. content = doc_path.read_text()
  329. rel_path = str(doc_path.relative_to(ROOT))
  330. issues.extend(scan_backticks(rel_path, content, valid, aliases))
  331. issues.extend(scan_yaml_blocks(rel_path, content, valid, aliases))
  332. if not issues:
  333. print("No config key casing issues found.")
  334. return 0
  335. print(f"Issues: {len(issues)}")
  336. for issue in issues:
  337. print(
  338. f"{issue.path}:{issue.line}: [{issue.kind}] "
  339. f"`{issue.found}` should be `{issue.expected}`"
  340. )
  341. return 1
  342. if __name__ == "__main__":
  343. sys.exit(main())