microsoft/openvmm
Publicmirrored fromhttps://github.com/microsoft/openvmmAvailable
.github/scripts/dep-review.js
530lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | // Dependency review script — detects external (3rd-party) crate changes in |
| 5 | // Cargo.lock and dependency containment policy violations. When issues are |
| 6 | // found, requests review from the dependency reviewer team. When issues are |
| 7 | // resolved, removes the review request. |
| 8 | // |
| 9 | // Can be run in two modes: |
| 10 | // 1. GitHub Actions (via actions/github-script): pass github, context, core |
| 11 | // 2. Local testing: `node dep-review.js --base <file> --pr <file> [--manifest <root-Cargo.toml> --policy <dep-policy.json>]` |
| 12 | |
| 13 | "use strict"; |
| 14 | |
| 15 | // --- Pure functions (no GitHub API, easily testable) --- |
| 16 | |
| 17 | /** |
| 18 | * Parse a Cargo.lock file into a Set of "name\tversion\tsource" strings |
| 19 | * for external packages (those with a `source` field). |
| 20 | * |
| 21 | * Cargo.lock can contain multiple versions of the same crate from the |
| 22 | * same registry (e.g., windows-sys 0.48 and 0.52), so each unique |
| 23 | * (name, version, source) tuple is tracked independently. |
| 24 | */ |
| 25 | function parseExternalDeps(content) { |
| 26 | const deps = new Set(); |
| 27 | const blocks = content.split(/\n(?=\[\[package\]\])/); |
| 28 | for (const block of blocks) { |
| 29 | if (!block.includes("[[package]]")) continue; |
| 30 | const name = block.match(/^name\s*=\s*"(.+?)"/m)?.[1]; |
| 31 | const version = block.match(/^version\s*=\s*"(.+?)"/m)?.[1]; |
| 32 | const source = block.match(/^source\s*=\s*"(.+?)"/m)?.[1]; |
| 33 | if (!name || !version) continue; |
| 34 | if (source) { |
| 35 | deps.add(`${name}\t${version}\t${source}`); |
| 36 | } |
| 37 | } |
| 38 | return deps; |
| 39 | } |
| 40 | |
| 41 | /** Format a source string for human-readable display. */ |
| 42 | function fmtSource(source) { |
| 43 | if (source.startsWith("registry+")) return ""; |
| 44 | if (source.startsWith("git+")) { |
| 45 | const url = source.replace(/^git\+/, "").replace(/#.*$/, ""); |
| 46 | return ` (${url})`; |
| 47 | } |
| 48 | return ` (${source})`; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Diff two parsed dependency sets. |
| 53 | * Returns { added } array — entries present in prDeps but not baseDeps. |
| 54 | * Removals are not tracked because dropping a dependency doesn't require review. |
| 55 | */ |
| 56 | function diffDeps(baseDeps, prDeps) { |
| 57 | const added = []; |
| 58 | |
| 59 | for (const key of prDeps) { |
| 60 | if (!baseDeps.has(key)) { |
| 61 | const [name, version, source] = key.split("\t"); |
| 62 | added.push({ name, version, source }); |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | return { added }; |
| 67 | } |
| 68 | |
| 69 | /** Build a markdown summary of dependency changes. */ |
| 70 | function buildSummary(diff) { |
| 71 | const { added } = diff; |
| 72 | let summary = "### External dependency changes detected\n\n"; |
| 73 | |
| 74 | if (added.length > 0) { |
| 75 | summary += "**New external crate versions:**\n"; |
| 76 | for (const d of added) { |
| 77 | summary += `- \`${d.name}\` ${d.version}${fmtSource(d.source)}\n`; |
| 78 | } |
| 79 | summary += "\n"; |
| 80 | } |
| 81 | |
| 82 | return summary; |
| 83 | } |
| 84 | |
| 85 | // --- Dependency graph policy checks (reason 2a/2b) --- |
| 86 | |
| 87 | /** |
| 88 | * Parse root Cargo.toml [workspace.dependencies] to build a map of |
| 89 | * crate name → directory path (only for path-based / internal deps). |
| 90 | * |
| 91 | * NOTE: This only covers crates listed in [workspace.dependencies], not all |
| 92 | * workspace members. Crates that are workspace members but not listed there |
| 93 | * (e.g., fuzz targets) won't be covered by containment checks. In practice |
| 94 | * this is fine — such crates are leaf crates unlikely to introduce |
| 95 | * cross-boundary deps, and resolving all members would require fetching |
| 96 | * individual Cargo.toml files for each member. |
| 97 | */ |
| 98 | function parseCratePathMap(cargoTomlContent) { |
| 99 | const map = new Map(); |
| 100 | // Match inline table entries with a path key: |
| 101 | // crate_name = { path = "some/dir", ... } |
| 102 | // Handles optional extra keys before/after `path`. |
| 103 | const regex = /^([\w][\w-]*)\s*=\s*\{[^}]*path\s*=\s*"([^"]+)"[^}]*\}/gm; |
| 104 | let match; |
| 105 | while ((match = regex.exec(cargoTomlContent)) !== null) { |
| 106 | map.set(match[1], match[2]); |
| 107 | } |
| 108 | return map; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Parse Cargo.lock to extract the internal dependency graph. |
| 113 | * Returns { graph: Map<name, string[]>, internalCrates: Set<name> }. |
| 114 | * Only internal crates (those without a `source` field) are included. |
| 115 | */ |
| 116 | function parseInternalDepGraph(lockContent) { |
| 117 | const graph = new Map(); |
| 118 | const internalCrates = new Set(); |
| 119 | |
| 120 | const blocks = lockContent.split(/\n(?=\[\[package\]\])/); |
| 121 | for (const block of blocks) { |
| 122 | if (!block.includes("[[package]]")) continue; |
| 123 | const name = block.match(/^name\s*=\s*"(.+?)"/m)?.[1]; |
| 124 | const source = block.match(/^source\s*=\s*"(.+?)"/m)?.[1]; |
| 125 | if (!name) continue; |
| 126 | if (source) continue; // external crate |
| 127 | |
| 128 | internalCrates.add(name); |
| 129 | |
| 130 | const depsMatch = block.match(/^dependencies\s*=\s*\[([\s\S]*?)\]/m); |
| 131 | if (depsMatch) { |
| 132 | const deps = []; |
| 133 | for (const entry of depsMatch[1].matchAll(/"([^"]+)"/g)) { |
| 134 | // Entries are "name" or "name version" |
| 135 | deps.push(entry[1].split(" ")[0]); |
| 136 | } |
| 137 | graph.set(name, deps); |
| 138 | } else { |
| 139 | graph.set(name, []); |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | return { graph, internalCrates }; |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Check containment policies against the internal dep graph. |
| 148 | * A containment rule says: crates under `prefix` may only have internal |
| 149 | * dependencies on other crates under `prefix`. |
| 150 | * |
| 151 | * Returns an array of violation objects. |
| 152 | */ |
| 153 | function checkContainment(graph, internalCrates, pathMap, policy) { |
| 154 | const violations = []; |
| 155 | |
| 156 | for (const rule of policy.containment || []) { |
| 157 | const prefix = rule.prefix; |
| 158 | |
| 159 | for (const [name, path] of pathMap) { |
| 160 | if (!path.startsWith(prefix)) continue; |
| 161 | if (!graph.has(name)) continue; |
| 162 | |
| 163 | for (const dep of graph.get(name)) { |
| 164 | // Only check edges to other internal crates |
| 165 | if (!internalCrates.has(dep)) continue; |
| 166 | const depPath = pathMap.get(dep); |
| 167 | if (!depPath) continue; // can't resolve — skip |
| 168 | if (depPath.startsWith(prefix)) continue; // same prefix — ok |
| 169 | |
| 170 | violations.push({ |
| 171 | crate: name, |
| 172 | cratePath: path, |
| 173 | dep, |
| 174 | depPath, |
| 175 | rule: rule.description || `${prefix} containment`, |
| 176 | }); |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | return violations; |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * Diff containment violations between base and PR. |
| 186 | * Only returns *new* violations (present in PR but not in base), |
| 187 | * so existing violations are grandfathered. |
| 188 | */ |
| 189 | function diffContainmentViolations(baseViolations, prViolations) { |
| 190 | const baseSet = new Set( |
| 191 | baseViolations.map((v) => `${v.crate}\t${v.dep}`) |
| 192 | ); |
| 193 | return prViolations.filter((v) => !baseSet.has(`${v.crate}\t${v.dep}`)); |
| 194 | } |
| 195 | |
| 196 | /** Build a markdown summary of containment violations. */ |
| 197 | function buildPolicySummary(violations) { |
| 198 | if (violations.length === 0) return ""; |
| 199 | |
| 200 | let summary = "### Dependency containment violations\n\n"; |
| 201 | summary += |
| 202 | "The following new internal dependency edges violate containment policy:\n\n"; |
| 203 | for (const v of violations) { |
| 204 | summary += |
| 205 | `- \`${v.crate}\` (${v.cratePath}) → \`${v.dep}\` (${v.depPath}) — ${v.rule}\n`; |
| 206 | } |
| 207 | summary += "\n"; |
| 208 | return summary; |
| 209 | } |
| 210 | |
| 211 | // --- GitHub Actions entrypoint --- |
| 212 | |
| 213 | const DEP_REVIEW_TEAM = "openvmm-dependency-reviewers"; |
| 214 | |
| 215 | /** |
| 216 | * Main function called from actions/github-script. |
| 217 | * Requests or removes review from the dependency reviewer team based on |
| 218 | * whether external dep changes or policy violations are detected. |
| 219 | * |
| 220 | * @param {object} github - Octokit REST client |
| 221 | * @param {object} context - GitHub Actions context |
| 222 | * @param {object} core - GitHub Actions core (for setFailed) |
| 223 | */ |
| 224 | async function run(github, context, core) { |
| 225 | const prNumber = context.payload.pull_request.number; |
| 226 | const baseSha = context.payload.pull_request.base.sha; |
| 227 | |
| 228 | // Step 1: Check if Cargo.lock was modified |
| 229 | let allFiles = []; |
| 230 | let page = 1; |
| 231 | const MAX_PAGES = 30; |
| 232 | while (page <= MAX_PAGES) { |
| 233 | const { data: files } = await github.rest.pulls.listFiles({ |
| 234 | owner: context.repo.owner, |
| 235 | repo: context.repo.repo, |
| 236 | pull_number: prNumber, |
| 237 | per_page: 100, |
| 238 | page, |
| 239 | }); |
| 240 | if (files.length === 0) break; |
| 241 | allFiles = allFiles.concat(files); |
| 242 | if (files.length < 100) break; |
| 243 | page++; |
| 244 | } |
| 245 | |
| 246 | if (page > MAX_PAGES) { |
| 247 | core.warning( |
| 248 | `PR has more than ${MAX_PAGES * 100} changed files — ` + |
| 249 | `Cargo.lock detection may be incomplete. Assuming it changed.` |
| 250 | ); |
| 251 | } |
| 252 | |
| 253 | const lockfileChanged = page > MAX_PAGES || allFiles.some((f) => f.filename === "Cargo.lock"); |
| 254 | if (!lockfileChanged) { |
| 255 | console.log("Cargo.lock not modified — nothing to review."); |
| 256 | |
| 257 | // Remove any stale review request from a previous push that did touch Cargo.lock |
| 258 | try { |
| 259 | await github.rest.pulls.removeRequestedReviewers({ |
| 260 | owner: context.repo.owner, |
| 261 | repo: context.repo.repo, |
| 262 | pull_number: prNumber, |
| 263 | team_reviewers: [DEP_REVIEW_TEAM], |
| 264 | }); |
| 265 | console.log(`Removed stale review request from @microsoft/${DEP_REVIEW_TEAM}`); |
| 266 | } catch (e) { |
| 267 | if (e.status !== 422) { |
| 268 | console.log(`Note: failed to remove review request (${e.status}): ${e.message}`); |
| 269 | } |
| 270 | } |
| 271 | return; |
| 272 | } |
| 273 | |
| 274 | // Step 2: Fetch base and PR Cargo.lock via API |
| 275 | // Always fetch from the base repo — for forked PRs, use the merge ref |
| 276 | // (refs/pull/N/head) so we don't need access to the fork itself. |
| 277 | async function fetchFile(path, ref) { |
| 278 | const { data } = await github.rest.repos.getContent({ |
| 279 | owner: context.repo.owner, |
| 280 | repo: context.repo.repo, |
| 281 | path, |
| 282 | ref, |
| 283 | }); |
| 284 | if (data.type !== "file") { |
| 285 | throw new Error(`${path}: not a regular file`); |
| 286 | } |
| 287 | return Buffer.from(data.content, "base64").toString("utf8"); |
| 288 | } |
| 289 | |
| 290 | const prRef = `refs/pull/${prNumber}/head`; |
| 291 | |
| 292 | const baseContent = await fetchFile("Cargo.lock", baseSha); |
| 293 | const prContent = await fetchFile("Cargo.lock", prRef); |
| 294 | |
| 295 | // Step 3: Diff external deps |
| 296 | const baseDeps = parseExternalDeps(baseContent); |
| 297 | const prDeps = parseExternalDeps(prContent); |
| 298 | const diff = diffDeps(baseDeps, prDeps); |
| 299 | const hasExternalChanges = diff.added.length > 0; |
| 300 | |
| 301 | // Step 4: Check containment policies |
| 302 | const fs = require("fs"); |
| 303 | const path = require("path"); |
| 304 | const policyPath = path.join(__dirname, "..", "dep-policy.json"); |
| 305 | let newViolations = []; |
| 306 | if (fs.existsSync(policyPath)) { |
| 307 | const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); |
| 308 | |
| 309 | const baseManifest = await fetchFile("Cargo.toml", baseSha); |
| 310 | const prManifest = await fetchFile("Cargo.toml", prRef); |
| 311 | |
| 312 | const basePathMap = parseCratePathMap(baseManifest); |
| 313 | const prPathMap = parseCratePathMap(prManifest); |
| 314 | |
| 315 | const baseGraph = parseInternalDepGraph(baseContent); |
| 316 | const prGraph = parseInternalDepGraph(prContent); |
| 317 | |
| 318 | const baseViolations = checkContainment( |
| 319 | baseGraph.graph, baseGraph.internalCrates, basePathMap, policy |
| 320 | ); |
| 321 | const prViolations = checkContainment( |
| 322 | prGraph.graph, prGraph.internalCrates, prPathMap, policy |
| 323 | ); |
| 324 | newViolations = diffContainmentViolations(baseViolations, prViolations); |
| 325 | } |
| 326 | |
| 327 | const needsReview = hasExternalChanges || newViolations.length > 0; |
| 328 | |
| 329 | if (needsReview) { |
| 330 | // Build and log summary |
| 331 | let summary = ""; |
| 332 | if (hasExternalChanges) summary += buildSummary(diff); |
| 333 | if (newViolations.length > 0) summary += buildPolicySummary(newViolations); |
| 334 | console.log(summary); |
| 335 | |
| 336 | // Request review from the dependency team |
| 337 | console.log(`Requesting review from @microsoft/${DEP_REVIEW_TEAM}`); |
| 338 | await github.rest.pulls.requestReviewers({ |
| 339 | owner: context.repo.owner, |
| 340 | repo: context.repo.repo, |
| 341 | pull_number: prNumber, |
| 342 | team_reviewers: [DEP_REVIEW_TEAM], |
| 343 | }); |
| 344 | } else { |
| 345 | console.log( |
| 346 | "Cargo.lock changed, but no new or updated external dependencies " + |
| 347 | "were detected and no policy violations found. No dependency review required." |
| 348 | ); |
| 349 | |
| 350 | // Remove the review request if it was previously added |
| 351 | try { |
| 352 | await github.rest.pulls.removeRequestedReviewers({ |
| 353 | owner: context.repo.owner, |
| 354 | repo: context.repo.repo, |
| 355 | pull_number: prNumber, |
| 356 | team_reviewers: [DEP_REVIEW_TEAM], |
| 357 | }); |
| 358 | console.log(`Removed review request from @microsoft/${DEP_REVIEW_TEAM}`); |
| 359 | } catch (e) { |
| 360 | // 422 = team was not requested — that's fine. Log anything else. |
| 361 | if (e.status !== 422) { |
| 362 | console.log(`Note: failed to remove review request (${e.status}): ${e.message}`); |
| 363 | } |
| 364 | } |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | // --- Local CLI entrypoint --- |
| 369 | |
| 370 | async function localMain() { |
| 371 | const fs = require("fs"); |
| 372 | const path = require("path"); |
| 373 | const { execSync } = require("child_process"); |
| 374 | const args = process.argv.slice(2); |
| 375 | |
| 376 | if (args.includes("--help") || args.includes("-h")) { |
| 377 | console.log( |
| 378 | "Usage:\n" + |
| 379 | " node dep-review.js --check Compare working tree against merge-base with origin/main\n" + |
| 380 | " node dep-review.js --check <upstream> Compare working tree against merge-base with <upstream>\n" + |
| 381 | " node dep-review.js --base <file> --pr <file> [--manifest <Cargo.toml> --policy <dep-policy.json>]\n" + |
| 382 | "\nThe --check mode automatically finds the merge base, reads the base\n" + |
| 383 | "Cargo.lock/Cargo.toml from git, and compares against the working tree.\n" + |
| 384 | "It also auto-discovers dep-policy.json relative to the script location.\n" + |
| 385 | "\nIn --base/--pr mode, --manifest and --policy must be specified together\n" + |
| 386 | "for containment policy checks (both are optional if you only want dep diff)." |
| 387 | ); |
| 388 | process.exit(0); |
| 389 | } |
| 390 | |
| 391 | const checkIdx = args.indexOf("--check"); |
| 392 | let baseContent, prContent, baseManifestContent, prManifestContent; |
| 393 | |
| 394 | if (checkIdx !== -1) { |
| 395 | // --check mode: auto-detect merge base |
| 396 | const upstream = args[checkIdx + 1] && !args[checkIdx + 1].startsWith("--") |
| 397 | ? args[checkIdx + 1] |
| 398 | : "origin/main"; |
| 399 | |
| 400 | // Find repo root |
| 401 | const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); |
| 402 | |
| 403 | // Find merge base |
| 404 | let mergeBase; |
| 405 | try { |
| 406 | mergeBase = execSync(`git merge-base HEAD ${upstream}`, { encoding: "utf8" }).trim(); |
| 407 | } catch (e) { |
| 408 | console.error( |
| 409 | `Could not find merge base with '${upstream}'. ` + |
| 410 | `Make sure '${upstream}' exists (try: git fetch origin).` |
| 411 | ); |
| 412 | process.exit(1); |
| 413 | } |
| 414 | |
| 415 | const shortBase = execSync(`git rev-parse --short ${mergeBase}`, { encoding: "utf8" }).trim(); |
| 416 | console.log(`Comparing working tree against merge base ${shortBase} (with ${upstream})\n`); |
| 417 | |
| 418 | // Read base files from git |
| 419 | baseContent = execSync(`git show ${mergeBase}:Cargo.lock`, { encoding: "utf8" }); |
| 420 | baseManifestContent = execSync(`git show ${mergeBase}:Cargo.toml`, { encoding: "utf8" }); |
| 421 | |
| 422 | // Read PR files from working tree |
| 423 | prContent = fs.readFileSync(path.join(root, "Cargo.lock"), "utf8"); |
| 424 | prManifestContent = fs.readFileSync(path.join(root, "Cargo.toml"), "utf8"); |
| 425 | |
| 426 | // Auto-discover policy file |
| 427 | const policyPath = path.join(root, ".github", "dep-policy.json"); |
| 428 | let policy = null; |
| 429 | if (fs.existsSync(policyPath)) { |
| 430 | policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); |
| 431 | } |
| 432 | |
| 433 | const result = runChecks(baseContent, prContent, baseManifestContent, prManifestContent, policy); |
| 434 | process.exit(result ? 1 : 0); |
| 435 | |
| 436 | } else { |
| 437 | // Explicit --base / --pr mode |
| 438 | const baseIdx = args.indexOf("--base"); |
| 439 | const prIdx = args.indexOf("--pr"); |
| 440 | |
| 441 | if (baseIdx === -1 || prIdx === -1) { |
| 442 | console.error( |
| 443 | "Usage:\n" + |
| 444 | " node dep-review.js --check (auto merge-base mode)\n" + |
| 445 | " node dep-review.js --base <file> --pr <file> [--manifest <Cargo.toml>] [--policy <dep-policy.json>]" |
| 446 | ); |
| 447 | process.exit(1); |
| 448 | } |
| 449 | |
| 450 | baseContent = fs.readFileSync(args[baseIdx + 1], "utf8"); |
| 451 | prContent = fs.readFileSync(args[prIdx + 1], "utf8"); |
| 452 | |
| 453 | const manifestIdx = args.indexOf("--manifest"); |
| 454 | const policyIdx = args.indexOf("--policy"); |
| 455 | |
| 456 | let policy = null; |
| 457 | if (manifestIdx !== -1 && policyIdx !== -1) { |
| 458 | baseManifestContent = fs.readFileSync(args[manifestIdx + 1], "utf8"); |
| 459 | prManifestContent = baseManifestContent; // same manifest in explicit mode |
| 460 | policy = JSON.parse(fs.readFileSync(args[policyIdx + 1], "utf8")); |
| 461 | } |
| 462 | |
| 463 | const result = runChecks(baseContent, prContent, baseManifestContent, prManifestContent, policy); |
| 464 | process.exit(result ? 1 : 0); |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | /** |
| 469 | * Run all checks (external dep diff + policy). Returns true if issues were found. |
| 470 | */ |
| 471 | function runChecks(baseContent, prContent, baseManifest, prManifest, policy) { |
| 472 | let failed = false; |
| 473 | |
| 474 | // External dep diff |
| 475 | const baseDeps = parseExternalDeps(baseContent); |
| 476 | const prDeps = parseExternalDeps(prContent); |
| 477 | const diff = diffDeps(baseDeps, prDeps); |
| 478 | const hasExternalChanges = diff.added.length > 0; |
| 479 | |
| 480 | if (hasExternalChanges) { |
| 481 | console.log(buildSummary(diff)); |
| 482 | failed = true; |
| 483 | } |
| 484 | |
| 485 | // Policy checks |
| 486 | if (policy && baseManifest && prManifest) { |
| 487 | const basePathMap = parseCratePathMap(baseManifest); |
| 488 | const prPathMap = parseCratePathMap(prManifest); |
| 489 | |
| 490 | const baseGraph = parseInternalDepGraph(baseContent); |
| 491 | const prGraph = parseInternalDepGraph(prContent); |
| 492 | |
| 493 | const baseViolations = checkContainment( |
| 494 | baseGraph.graph, baseGraph.internalCrates, basePathMap, policy |
| 495 | ); |
| 496 | const prViolations = checkContainment( |
| 497 | prGraph.graph, prGraph.internalCrates, prPathMap, policy |
| 498 | ); |
| 499 | const newViolations = diffContainmentViolations(baseViolations, prViolations); |
| 500 | |
| 501 | if (newViolations.length > 0) { |
| 502 | console.log(buildPolicySummary(newViolations)); |
| 503 | failed = true; |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | if (!failed) { |
| 508 | console.log("No dependency review issues detected."); |
| 509 | } |
| 510 | return failed; |
| 511 | } |
| 512 | |
| 513 | // Export for testing and for actions/github-script |
| 514 | module.exports = { |
| 515 | parseExternalDeps, |
| 516 | fmtSource, |
| 517 | diffDeps, |
| 518 | buildSummary, |
| 519 | parseCratePathMap, |
| 520 | parseInternalDepGraph, |
| 521 | checkContainment, |
| 522 | diffContainmentViolations, |
| 523 | buildPolicySummary, |
| 524 | run, |
| 525 | }; |
| 526 | |
| 527 | // Run CLI if invoked directly |
| 528 | if (require.main === module) { |
| 529 | localMain(); |
| 530 | } |
| 531 | |