#!/usr/bin/env node "use strict"; const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); const legacyStem = String.fromCharCode(112, 111, 100, 107, 111, 112); const legacyAppDir = `luci-app-${legacyStem}-plus`; function usage() { console.error("Usage: config_contract_matrix.js --current --stable [--check]"); process.exit(2); } function parseArgs(argv) { const args = { check: false }; for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg === "--check") { args.check = true; } else if (arg === "--current" || arg === "--stable") { if (!argv[i + 1]) usage(); args[arg.slice(2)] = path.resolve(argv[++i]); } else { usage(); } } if (!args.current || !args.stable) usage(); return args; } function gitDescribe(repo) { const result = spawnSync("git", ["describe", "--tags", "--always", "HEAD"], { cwd: repo, encoding: "utf8", }); if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); const fallback = path.basename(repo).match(/[0-9]+\.[0-9]+\.[0-9]+(?:[.-][0-9]+)?/); return fallback ? fallback[0] : ""; } function readFileIfExists(file) { try { return fs.readFileSync(file, "utf8"); } catch { return ""; } } function walkFiles(dir, result = []) { if (!fs.existsSync(dir)) return result; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === ".git" || entry.name === "sandbox") continue; walkFiles(full, result); } else { result.push(full); } } return result; } function ensure(map, key) { if (!map.has(key)) { map.set(key, { name: key, ui: [], backend: [], defaults: [], examples: [], migrations: [], }); } return map.get(key); } function addUnique(array, value) { if (value && !array.includes(value)) array.push(value); } function rel(repo, file) { return path.relative(repo, file).replace(/\\/g, "/"); } function extractBraceBody(data, marker) { const markerIndex = data.indexOf(marker); if (markerIndex < 0) return ""; const start = data.indexOf("{", markerIndex); if (start < 0) return ""; let depth = 0; for (let i = start; i < data.length; i++) { const char = data[i]; if (char === "{") { depth++; } else if (char === "}") { depth--; if (depth === 0) return data.slice(start + 1, i); } } return ""; } function extractObjectKeys(data, constName) { const body = extractBraceBody(data, `const ${constName}`); return [...body.matchAll(/^\s*([A-Za-z0-9_]+)\s*:/gm)].map((match) => match[1]); } function extractFunctionFirstStringArgs(data, functionName, callName) { const body = extractBraceBody(data, `function ${functionName}`); const pattern = new RegExp(`${callName}\\(\\s*["']([^"']*)["']`, "g"); return [...body.matchAll(pattern)].map((match) => match[1]); } function extractFunctionSecondStringArgs(data, functionName, callName) { const body = extractBraceBody(data, `function ${functionName}`); const pattern = new RegExp(`${callName}\\(\\s*[^,]+,\\s*["']([^"']*)["']`, "g"); return [...body.matchAll(pattern)].map((match) => match[1]); } function extractFunctionObjectValues(data, functionName) { const body = extractBraceBody(data, `function ${functionName}`); return [...body.matchAll(/\bvalue:\s*["']([^"']*)["']/g)].map((match) => match[1]); } function enrichUiValues(fileData, option, block, values) { if (option === "action" && block.includes("populateActionOptionValues(")) { for (const value of extractFunctionFirstStringArgs(fileData, "populateActionOptionValues", "option.value")) addUnique(values, value); } if (option === "protocol" && block.includes("populateProtocolValues(")) { for (const constName of ["TAILSCALE_PROTOCOL_LABELS", "BASE_PROTOCOL_LABELS", "EXTENDED_PROTOCOL_LABELS", "CUSTOM_PROTOCOL_LABELS"]) { for (const value of extractObjectKeys(fileData, constName)) addUnique(values, value); } } if (option === "transport" && block.includes("populateTransportValues(")) { for (const value of extractFunctionSecondStringArgs(fileData, "populateTransportValues", "addOptionValue")) addUnique(values, value); } } function extractUi(repo, fields) { const currentViewDir = path.join( repo, "luci-app-tachyon", "htdocs", "luci-static", "resources", "view", "tachyon", ); const legacyViewDir = path.join(repo, legacyAppDir, "htdocs", "luci-static", "resources", "view", legacyStem); const viewDir = fs.existsSync(currentViewDir) ? currentViewDir : legacyViewDir; const files = walkFiles(viewDir).filter((file) => file.endsWith(".js")); for (const file of files) { const data = readFileIfExists(file); const optionRe = /\.(option|taboption)\(\s*(?:(["'])[^"']+\2\s*,\s*)?([^,\n]+)\s*,\s*["']([^"']+)["']/g; const matches = [...data.matchAll(optionRe)]; for (let i = 0; i < matches.length; i++) { const match = matches[i]; const option = match[4]; const start = match.index; const end = i + 1 < matches.length ? matches[i + 1].index : data.length; const block = data.slice(start, end); const field = ensure(fields, option); const type = match[3].trim().replace(/\s+/g, " "); const defaults = [...block.matchAll(/\bo\.default\s*=\s*["']([^"']*)["']/g)].map((m) => m[1]); const values = [...block.matchAll(/\bo\.value\(\s*["']([^"']*)["']/g)].map((m) => m[1]); enrichUiValues(data, option, block, values); addUnique( field.ui, JSON.stringify({ file: rel(repo, file), type, default: defaults[0] || "", values: values.sort(), }), ); } const urltestFilterModeValues = extractFunctionObjectValues(data, "urlTestFilterModeChoices"); if (urltestFilterModeValues.length) { const field = ensure(fields, "urltest_filter_mode"); addUnique( field.ui, JSON.stringify({ file: rel(repo, file), type: "custom URLTest settings", default: "disabled", values: urltestFilterModeValues.sort(), }), ); } } } function extractConfigDefaults(repo, fields) { const currentFile = path.join(repo, "tachyon", "files", "etc", "config", "tachyon"); const legacyFile = path.join(repo, legacyStem, "files", "etc", "config", legacyStem); const file = fs.existsSync(currentFile) ? currentFile : legacyFile; const data = readFileIfExists(file); for (const line of data.split(/\n/)) { const match = line.match(/^\s*(#\s*)?(option|list)\s+([A-Za-z0-9_]+)\s+['"]?([^'"]*)/); if (!match) continue; const field = ensure(fields, match[3]); const item = `${rel(repo, file)}:${match[2]}:${match[4]}`; addUnique(match[1] ? field.examples : field.defaults, item); } } function extractBackend(repo, fields) { const backendRoot = fs.existsSync(path.join(repo, "tachyon")) ? "tachyon" : legacyStem; const roots = [ path.join(repo, backendRoot, "files", "usr", "lib"), path.join(repo, backendRoot, "files", "usr", "bin"), path.join(repo, backendRoot, "files", "etc", "init.d"), ]; const files = roots .flatMap((root) => walkFiles(root)) .filter((file) => /\.(sh|uc)$/.test(file) || ["tachyon", legacyStem].includes(path.basename(file))); const installer = path.join(repo, "install.sh"); if (fs.existsSync(installer)) files.push(installer); const shellOptionRe = /\bconfig_(?:get|get_bool|list_foreach)\s+\S+\s+(?:"[^"]+"|'[^']+'|\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]+\})\s+["']([A-Za-z0-9_]+)["']/g; const ucodeOptionRe = /\b(?:option|list_option|bool_option|int_option)\(\s*[^,\n]+,\s*["']([A-Za-z0-9_]+)["']/g; const ucodeStaticOptionKeyArrayRe = /^\s*\[\s*["']([A-Za-z0-9_]+)["']\s*,/gm; const migrationRe = new RegExp(`\\b(?:tachyon|${legacyStem})_uci_(?:set_option|set_option_if_missing|delete_option|add_list_unique)\\s+["'$A-Za-z0-9_{}.-]+\\s+["']([A-Za-z0-9_]+)["']`, "g"); const ucodeMigrationRe = /\b(?:set_option|set_option_if_missing|delete_option|add_list_unique)\(\s*[^,\n]+,\s*[^,\n]+,\s*["']([A-Za-z0-9_]+)["']/g; for (const file of files) { const data = readFileIfExists(file); const backendScanData = data.replace(/\\"/g, '"'); for (const match of backendScanData.matchAll(shellOptionRe)) { addUnique(ensure(fields, match[1]).backend, rel(repo, file)); } for (const match of backendScanData.matchAll(ucodeOptionRe)) { addUnique(ensure(fields, match[1]).backend, rel(repo, file)); } if (backendScanData.includes("option(server, field[0]")) { for (const match of backendScanData.matchAll(ucodeStaticOptionKeyArrayRe)) { addUnique(ensure(fields, match[1]).backend, rel(repo, file)); } } for (const match of backendScanData.matchAll(migrationRe)) { addUnique(ensure(fields, match[1]).migrations, rel(repo, file)); } for (const match of backendScanData.matchAll(ucodeMigrationRe)) { addUnique(ensure(fields, match[1]).migrations, rel(repo, file)); } } } function extractRepo(repo) { const fields = new Map(); extractUi(repo, fields); extractConfigDefaults(repo, fields); extractBackend(repo, fields); return fields; } function publicPresence(field) { return field && (field.ui.length || field.backend.length || field.defaults.length || field.examples.length || field.migrations.length); } function compactField(field) { if (!field) return null; return { ui: field.ui.map((item) => JSON.parse(item)).sort((a, b) => a.file.localeCompare(b.file) || a.type.localeCompare(b.type)), backend: field.backend.slice().sort(), defaults: field.defaults.slice().sort(), examples: field.examples.slice().sort(), migrations: field.migrations.slice().sort(), }; } function classify(stable, current) { if (publicPresence(stable) && publicPresence(current)) return "supported"; if (publicPresence(stable) && current && current.migrations.length) return "migrated"; if (publicPresence(stable)) return "missing_current"; return "added_current"; } function buildMatrix(currentRepo, stableRepo) { const stable = extractRepo(stableRepo); const current = extractRepo(currentRepo); const names = Array.from(new Set([...stable.keys(), ...current.keys()])).sort(); const fields = names.map((name) => { const stableField = stable.get(name); const currentField = current.get(name); return { name, status: classify(stableField, currentField), stable: compactField(stableField), current: compactField(currentField), }; }); const summary = {}; for (const field of fields) summary[field.status] = (summary[field.status] || 0) + 1; return { stable: { path: stableRepo, version: gitDescribe(stableRepo), }, current: { path: currentRepo, version: gitDescribe(currentRepo), }, summary, fields, }; } function main() { const args = parseArgs(process.argv); const matrix = buildMatrix(args.current, args.stable); process.stdout.write(JSON.stringify(matrix, null, 2) + "\n"); if (args.check) { const missing = matrix.fields.filter((field) => field.status === "missing_current"); if (missing.length) { console.error(`Missing current config fields from stable contract: ${missing.map((f) => f.name).join(", ")}`); process.exit(1); } } } main();