|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Synchronize missing rule and test files from typescript-eslint into rslint. |
| 5 | + * |
| 6 | + * - Discovers remote rules in typescript-eslint at: |
| 7 | + * - packages/eslint-plugin/src/rules/<rule>.ts |
| 8 | + * - packages/eslint-plugin/tests/rules/<rule>.test.ts(x) |
| 9 | + * - Maps kebab-case rule names to snake_case directory/file names in rslint. |
| 10 | + * - Checks existing files in internal/rules and only fetches missing ones. |
| 11 | + * - Writes content as Go files with the original TS content wrapped in comments: |
| 12 | + * - internal/rules/<snake>/<snake>.go |
| 13 | + * - internal/rules/<snake>/<snake>_test.go |
| 14 | + * |
| 15 | + * Usage: |
| 16 | + * node scripts/sync-tse-rules.mjs [--dry-run] [--only=<rule-name>] |
| 17 | + * |
| 18 | + * Optional env: |
| 19 | + * - RSLINT_ROOT: Absolute path to repo root (defaults to cwd) |
| 20 | + * - GITHUB_TOKEN or GH_TOKEN: For higher GitHub API rate limits |
| 21 | + */ |
| 22 | + |
| 23 | +import fs from 'node:fs/promises'; |
| 24 | +import path from 'node:path'; |
| 25 | +import process from 'node:process'; |
| 26 | + |
| 27 | +const GITHUB_API_BASE = 'https://api.github.com'; |
| 28 | +const RAW_BASE = 'https://raw.githubusercontent.com/typescript-eslint/typescript-eslint/main'; |
| 29 | +const REMOTE_RULES_DIR = 'packages/eslint-plugin/src/rules'; |
| 30 | +const REMOTE_TESTS_DIR = 'packages/eslint-plugin/tests/rules'; |
| 31 | + |
| 32 | +const ROOT = process.env.RSLINT_ROOT || process.cwd(); |
| 33 | +const LOCAL_RULES_DIR = path.resolve(ROOT, 'internal', 'rules'); |
| 34 | + |
| 35 | +const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; |
| 36 | +const baseHeaders = { |
| 37 | + 'User-Agent': 'rslint-sync-script', |
| 38 | + 'Accept': 'application/vnd.github+json', |
| 39 | + ...(token ? { Authorization: `Bearer ${token}` } : {}), |
| 40 | +}; |
| 41 | + |
| 42 | +function toSnakeCase(kebab) { |
| 43 | + return kebab.replace(/-/g, '_'); |
| 44 | +} |
| 45 | + |
| 46 | +async function listRemoteDirectory(relativePath) { |
| 47 | + const url = `${GITHUB_API_BASE}/repos/typescript-eslint/typescript-eslint/contents/${relativePath}`; |
| 48 | + const res = await fetch(url, { headers: baseHeaders }); |
| 49 | + if (!res.ok) { |
| 50 | + throw new Error(`Failed to list ${relativePath}: ${res.status} ${res.statusText}`); |
| 51 | + } |
| 52 | + return res.json(); |
| 53 | +} |
| 54 | + |
| 55 | +async function getRemoteRuleNames() { |
| 56 | + const items = await listRemoteDirectory(REMOTE_RULES_DIR); |
| 57 | + return items |
| 58 | + .filter((i) => i.type === 'file' && i.name.endsWith('.ts')) |
| 59 | + .map((i) => i.name) |
| 60 | + // exclude rule index aggregator and any non-rule helpers |
| 61 | + .filter((name) => name !== 'index.ts') |
| 62 | + .map((name) => name.replace(/\.ts$/, '')) |
| 63 | + .sort(); |
| 64 | +} |
| 65 | + |
| 66 | +async function getRemoteTestsMap() { |
| 67 | + const items = await listRemoteDirectory(REMOTE_TESTS_DIR); |
| 68 | + const map = new Map(); // ruleName (kebab) -> 'ts' | 'tsx' |
| 69 | + for (const i of items) { |
| 70 | + if (i.type !== 'file') continue; |
| 71 | + const m = i.name.match(/^(.+)\.test\.(tsx?)$/); |
| 72 | + if (m) map.set(m[1], m[2]); |
| 73 | + } |
| 74 | + return map; |
| 75 | +} |
| 76 | + |
| 77 | +async function readLocalState() { |
| 78 | + const state = new Map(); // snake -> { ruleExists, testExists } |
| 79 | + let dirents = []; |
| 80 | + try { |
| 81 | + dirents = await fs.readdir(LOCAL_RULES_DIR, { withFileTypes: true }); |
| 82 | + } catch (e) { |
| 83 | + // If the directory doesn't exist yet, treat as empty |
| 84 | + if (e && e.code !== 'ENOENT') throw e; |
| 85 | + } |
| 86 | + for (const d of dirents) { |
| 87 | + if (!d.isDirectory()) continue; |
| 88 | + const snake = d.name; |
| 89 | + const rulePath = path.join(LOCAL_RULES_DIR, snake, `${snake}.go`); |
| 90 | + const testPath = path.join(LOCAL_RULES_DIR, snake, `${snake}_test.go`); |
| 91 | + const [ruleExists, testExists] = await Promise.all([ |
| 92 | + fs.access(rulePath).then(() => true).catch(() => false), |
| 93 | + fs.access(testPath).then(() => true).catch(() => false), |
| 94 | + ]); |
| 95 | + state.set(snake, { ruleExists, testExists }); |
| 96 | + } |
| 97 | + return state; |
| 98 | +} |
| 99 | + |
| 100 | +async function fetchRawText(relativePath) { |
| 101 | + const url = `${RAW_BASE}/${relativePath}`; |
| 102 | + const headers = token |
| 103 | + ? { ...baseHeaders, Accept: 'application/vnd.github.raw' } |
| 104 | + : baseHeaders; |
| 105 | + const res = await fetch(url, { headers }); |
| 106 | + if (!res.ok) return null; |
| 107 | + return res.text(); |
| 108 | +} |
| 109 | + |
| 110 | +function buildGoFile(packageName, srcRelativePath, content, kind) { |
| 111 | + const header = [ |
| 112 | + '// Code generated by scripts/sync-tse-rules.mjs; DO NOT EDIT.', |
| 113 | + `// Source: typescript-eslint/${srcRelativePath}`, |
| 114 | + `// Kind: ${kind}`, |
| 115 | + `// Retrieved: ${new Date().toISOString()}`, |
| 116 | + '', |
| 117 | + `package ${packageName}`, |
| 118 | + '', |
| 119 | + ].join('\n'); |
| 120 | + |
| 121 | + const normalized = content.replace(/\r\n/g, '\n'); |
| 122 | + const body = normalized |
| 123 | + .split('\n') |
| 124 | + .map((line) => `// ${line}`) |
| 125 | + .join('\n'); |
| 126 | + return header + body + '\n'; |
| 127 | +} |
| 128 | + |
| 129 | +async function ensureDir(dir) { |
| 130 | + await fs.mkdir(dir, { recursive: true }); |
| 131 | +} |
| 132 | + |
| 133 | +async function main() { |
| 134 | + const args = new Set(process.argv.slice(2)); |
| 135 | + const dryRun = args.has('--dry-run'); |
| 136 | + const onlyArg = Array.from(args).find((a) => a.startsWith('--only=')); |
| 137 | + const onlyRule = onlyArg ? onlyArg.slice('--only='.length) : null; // kebab-case |
| 138 | + |
| 139 | + console.log(`Local rules directory: ${LOCAL_RULES_DIR}`); |
| 140 | + const [remoteRules, remoteTestsMap, localState] = await Promise.all([ |
| 141 | + getRemoteRuleNames(), |
| 142 | + getRemoteTestsMap(), |
| 143 | + readLocalState(), |
| 144 | + ]); |
| 145 | + |
| 146 | + const candidates = onlyRule |
| 147 | + ? remoteRules.filter((r) => r === onlyRule) |
| 148 | + : remoteRules; |
| 149 | + |
| 150 | + let created = 0; |
| 151 | + let skipped = 0; |
| 152 | + let errors = 0; |
| 153 | + |
| 154 | + for (const ruleName of candidates) { |
| 155 | + const snake = toSnakeCase(ruleName); |
| 156 | + const pkg = snake; |
| 157 | + const dir = path.join(LOCAL_RULES_DIR, snake); |
| 158 | + const ruleGo = path.join(dir, `${snake}.go`); |
| 159 | + const testGo = path.join(dir, `${snake}_test.go`); |
| 160 | + const local = localState.get(snake) || { ruleExists: false, testExists: false }; |
| 161 | + |
| 162 | + const needRule = !local.ruleExists; |
| 163 | + const needTest = !local.testExists; |
| 164 | + if (!needRule && !needTest) { |
| 165 | + skipped++; |
| 166 | + continue; |
| 167 | + } |
| 168 | + |
| 169 | + const ruleSrcRel = `${REMOTE_RULES_DIR}/${ruleName}.ts`; |
| 170 | + const testExt = remoteTestsMap.get(ruleName) || 'ts'; |
| 171 | + const testSrcRel = `${REMOTE_TESTS_DIR}/${ruleName}.test.${testExt}`; |
| 172 | + |
| 173 | + const [ruleTs, testTs] = await Promise.all([ |
| 174 | + needRule ? fetchRawText(ruleSrcRel) : Promise.resolve(null), |
| 175 | + needTest ? fetchRawText(testSrcRel) : Promise.resolve(null), |
| 176 | + ]); |
| 177 | + |
| 178 | + try { |
| 179 | + await ensureDir(dir); |
| 180 | + |
| 181 | + if (needRule) { |
| 182 | + if (ruleTs) { |
| 183 | + const goText = buildGoFile(pkg, ruleSrcRel, ruleTs, 'rule'); |
| 184 | + if (dryRun) { |
| 185 | + console.log(`[dry-run] Would write ${ruleGo}`); |
| 186 | + } else { |
| 187 | + await fs.writeFile(ruleGo, goText, 'utf8'); |
| 188 | + console.log(`Wrote ${ruleGo}`); |
| 189 | + } |
| 190 | + if (!dryRun) created++; |
| 191 | + } else { |
| 192 | + console.warn(`Remote rule missing or failed to fetch: ${ruleName}`); |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + if (needTest) { |
| 197 | + if (testTs) { |
| 198 | + const goText = buildGoFile(pkg, testSrcRel, testTs, 'test'); |
| 199 | + if (dryRun) { |
| 200 | + console.log(`[dry-run] Would write ${testGo}`); |
| 201 | + } else { |
| 202 | + await fs.writeFile(testGo, goText, 'utf8'); |
| 203 | + console.log(`Wrote ${testGo}`); |
| 204 | + } |
| 205 | + if (!dryRun) created++; |
| 206 | + } else { |
| 207 | + console.warn(`Remote test missing or failed to fetch: ${ruleName}`); |
| 208 | + } |
| 209 | + } |
| 210 | + } catch (e) { |
| 211 | + errors++; |
| 212 | + console.error(`Error writing files for ${snake}:`, e); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + console.log(`Done. created=${created} skipped=${skipped} errors=${errors}`); |
| 217 | +} |
| 218 | + |
| 219 | +main().catch((e) => { |
| 220 | + console.error(e); |
| 221 | + process.exit(1); |
| 222 | +}); |
| 223 | + |
| 224 | + |
0 commit comments