audit-settings-placeholders.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. /*
  2. * Audit for zh-CN placeholder strings accidentally copied into other locales' split settings.
  3. *
  4. * Rule:
  5. * - For each non-canonical locale, if a leaf string equals the canonical (zh-CN) leaf string at
  6. * the same key path, consider it a "placeholder candidate".
  7. *
  8. * Output includes:
  9. * - locale
  10. * - relFile (relative to messages/<locale>, e.g. settings/config.json or dashboard.json)
  11. * - key (full key path, prefixed by file name, e.g. config.form.enableHttp2Desc)
  12. * - value (target value, equals zh-CN)
  13. * - reason (stable machine-readable string)
  14. */
  15. const fs = require("node:fs");
  16. const path = require("node:path");
  17. const sync = require("./sync-settings-keys.js");
  18. const CANONICAL = "zh-CN";
  19. const DEFAULT_TARGET_LOCALES = ["en", "ja", "ru", "zh-TW"];
  20. const SCOPES = ["settings", "dashboard", "myUsage"];
  21. function isObject(v) {
  22. return v && typeof v === "object" && !Array.isArray(v);
  23. }
  24. function flatten(obj, prefix = "") {
  25. const out = {};
  26. for (const [k, v] of Object.entries(obj || {})) {
  27. const key = prefix ? `${prefix}.${k}` : k;
  28. if (isObject(v)) Object.assign(out, flatten(v, key));
  29. else out[key] = v;
  30. }
  31. return out;
  32. }
  33. function listJsonFiles(dir) {
  34. const out = [];
  35. const walk = (d) => {
  36. for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
  37. const full = path.join(d, entry.name);
  38. if (entry.isDirectory()) {
  39. walk(full);
  40. continue;
  41. }
  42. if (entry.isFile() && entry.name.endsWith(".json")) out.push(full);
  43. }
  44. };
  45. if (fs.existsSync(dir)) walk(dir);
  46. return out;
  47. }
  48. function fileToKeyPrefix(relFile) {
  49. const segs = relFile.replace(/\.json$/, "").split(path.sep);
  50. if (segs[segs.length - 1] === "strings") return segs.slice(0, -1).join(".");
  51. return segs.join(".");
  52. }
  53. function loadJson(p) {
  54. return JSON.parse(fs.readFileSync(p, "utf8"));
  55. }
  56. function hasHanChars(s) {
  57. return /[\u4E00-\u9FFF]/.test(s);
  58. }
  59. function loadAllowlist(allowlistPath) {
  60. if (!allowlistPath) return null;
  61. if (!fs.existsSync(allowlistPath)) return null;
  62. const data = loadJson(allowlistPath);
  63. const entries = Array.isArray(data?.entries) ? data.entries : [];
  64. const glossary = Array.isArray(data?.glossary) ? data.glossary : [];
  65. return { entries, glossary };
  66. }
  67. function isAllowedByAllowlist(row, allowlist) {
  68. if (!allowlist) return { allowed: false, allowReason: null };
  69. for (const term of allowlist.glossary || []) {
  70. if (typeof term === "string" && term.length > 0 && row.value.includes(term)) {
  71. return { allowed: true, allowReason: `glossary:${term}` };
  72. }
  73. }
  74. for (const entry of allowlist.entries || []) {
  75. if (!entry || typeof entry !== "object") continue;
  76. const reason = typeof entry.reason === "string" && entry.reason ? entry.reason : "allowlisted";
  77. if (typeof entry.key === "string" && entry.key === row.key) {
  78. return { allowed: true, allowReason: `key:${reason}` };
  79. }
  80. if (typeof entry.keyPrefix === "string" && row.key.startsWith(entry.keyPrefix)) {
  81. return { allowed: true, allowReason: `keyPrefix:${reason}` };
  82. }
  83. if (typeof entry.keyRegex === "string") {
  84. const re = new RegExp(entry.keyRegex);
  85. if (re.test(row.key)) return { allowed: true, allowReason: `keyRegex:${reason}` };
  86. }
  87. if (typeof entry.valueRegex === "string") {
  88. const re = new RegExp(entry.valueRegex);
  89. if (re.test(row.value)) return { allowed: true, allowReason: `valueRegex:${reason}` };
  90. }
  91. }
  92. return { allowed: false, allowReason: null };
  93. }
  94. function normalizeScopes(scopes) {
  95. if (typeof scopes === "string") return normalizeScopes([scopes]);
  96. if (!scopes || scopes.length === 0) return ["settings"];
  97. const normalized = scopes
  98. .flatMap((s) => String(s).split(","))
  99. .map((s) => s.trim())
  100. .filter(Boolean);
  101. const unknown = normalized.filter((s) => !SCOPES.includes(s));
  102. if (unknown.length > 0) {
  103. throw new Error(`Unknown scope(s): ${unknown.join(", ")} (supported: ${SCOPES.join(", ")})`);
  104. }
  105. return normalized;
  106. }
  107. function normalizeLocales(locales) {
  108. if (typeof locales === "string") return normalizeLocales([locales]);
  109. if (!locales || locales.length === 0) return DEFAULT_TARGET_LOCALES;
  110. return locales
  111. .flatMap((s) => String(s).split(","))
  112. .map((s) => s.trim())
  113. .filter(Boolean);
  114. }
  115. function listCanonicalFilesForScope(messagesRoot, scope) {
  116. if (scope === "settings") {
  117. const cnDir = path.join(messagesRoot, CANONICAL, "settings");
  118. return listJsonFiles(cnDir).map((p) => {
  119. const rel = path.relative(cnDir, p);
  120. return {
  121. relFile: rel,
  122. canonicalPath: p,
  123. keyPrefix: fileToKeyPrefix(rel),
  124. };
  125. });
  126. }
  127. const file = `${scope}.json`;
  128. const canonicalPath = path.join(messagesRoot, CANONICAL, file);
  129. if (!fs.existsSync(canonicalPath)) return [];
  130. return [{ relFile: file, canonicalPath, keyPrefix: scope }];
  131. }
  132. function findSettingsPlaceholders({ messagesDir, locales, scopes, allowlistPath }) {
  133. const root = messagesDir || path.join(process.cwd(), "messages");
  134. const targets = normalizeLocales(locales);
  135. const scopeList = normalizeScopes(scopes);
  136. const allowlist = loadAllowlist(allowlistPath);
  137. let allowlistedCount = 0;
  138. const rows = [];
  139. for (const locale of targets) {
  140. for (const scope of scopeList) {
  141. const files = listCanonicalFilesForScope(root, scope);
  142. for (const f of files) {
  143. const tPath =
  144. scope === "settings"
  145. ? path.join(root, locale, "settings", f.relFile)
  146. : path.join(root, locale, f.relFile);
  147. if (!fs.existsSync(tPath)) continue;
  148. const cnObj = loadJson(f.canonicalPath);
  149. const tObj = loadJson(tPath);
  150. const cnFlat = flatten(cnObj);
  151. const tFlat = flatten(tObj);
  152. for (const [leafKey, cnVal] of Object.entries(cnFlat)) {
  153. const tVal = tFlat[leafKey];
  154. if (typeof cnVal !== "string" || typeof tVal !== "string") continue;
  155. if (!hasHanChars(cnVal)) continue;
  156. if (tVal !== cnVal) continue;
  157. const fullKey = f.keyPrefix
  158. ? leafKey
  159. ? `${f.keyPrefix}.${leafKey}`
  160. : f.keyPrefix
  161. : leafKey;
  162. rows.push({
  163. locale,
  164. relFile: f.relFile,
  165. key: fullKey,
  166. value: tVal,
  167. reason: "same_as_zh-CN",
  168. });
  169. }
  170. }
  171. }
  172. }
  173. const filtered = [];
  174. for (const row of rows) {
  175. const res = isAllowedByAllowlist(row, allowlist);
  176. if (res.allowed) {
  177. allowlistedCount += 1;
  178. continue;
  179. }
  180. filtered.push(row);
  181. }
  182. return {
  183. rows: filtered,
  184. allowlistedCount,
  185. byLocaleCount: filtered.reduce(
  186. (acc, r) => ((acc[r.locale] = (acc[r.locale] || 0) + 1), acc),
  187. {}
  188. ),
  189. };
  190. }
  191. function run(argv) {
  192. const fail = argv.includes("--fail");
  193. const messagesDirArg = argv.find((a) => a.startsWith("--messagesDir="));
  194. const messagesDir = messagesDirArg ? messagesDirArg.split("=", 2)[1] : undefined;
  195. const localesArg = argv.find((a) => a.startsWith("--locales="));
  196. const locales = localesArg ? localesArg.split("=", 2)[1] : undefined;
  197. const scopeArg = argv.find((a) => a.startsWith("--scope="));
  198. const scopes = scopeArg ? scopeArg.split("=", 2)[1] : undefined;
  199. const formatArg = argv.find((a) => a.startsWith("--format="));
  200. const format = formatArg ? formatArg.split("=", 2)[1] : "text";
  201. const allowlistArg = argv.find((a) => a.startsWith("--allowlist="));
  202. const allowlistPath = allowlistArg ? allowlistArg.split("=", 2)[1] : path.join(process.cwd(), "scripts", "audit-settings-placeholders.allowlist.json");
  203. const report = findSettingsPlaceholders({ messagesDir, locales, scopes, allowlistPath });
  204. const total = report.rows.length;
  205. if (total === 0) {
  206. return {
  207. exitCode: 0,
  208. lines: ["OK: no zh-CN placeholder candidates found in split settings."],
  209. };
  210. }
  211. if (format === "json") {
  212. return {
  213. exitCode: fail ? 1 : 0,
  214. lines: [JSON.stringify(report.rows, null, 2)],
  215. };
  216. }
  217. if (format === "tsv") {
  218. const lines = ["locale\trelFile\tkey\tvalue\treason"];
  219. for (const r of report.rows) {
  220. lines.push(`${r.locale}\t${r.relFile}\t${r.key}\t${r.value}\t${r.reason}`);
  221. }
  222. return { exitCode: fail ? 1 : 0, lines };
  223. }
  224. const lines = [`Found ${total} zh-CN placeholder candidates:`];
  225. for (const r of report.rows) {
  226. lines.push(`${r.locale}\t${r.relFile}\t${r.key}`);
  227. }
  228. return { exitCode: fail ? 1 : 0, lines };
  229. }
  230. module.exports = {
  231. findSettingsPlaceholders,
  232. flatten,
  233. listJsonFiles,
  234. fileToKeyPrefix,
  235. run,
  236. };
  237. if (require.main === module) {
  238. // Validate script API compatibility: sync script must remain require()-able.
  239. if (!sync || typeof sync.loadSplitSettings !== "function") {
  240. throw new Error("scripts/sync-settings-keys.js exports are not available (expected loadSplitSettings)");
  241. }
  242. const out = run(process.argv.slice(2));
  243. for (const line of out.lines) console.log(line); // eslint-disable-line no-console
  244. process.exit(out.exitCode);
  245. }