Skip to content

Commit 8644fec

Browse files
chore(scripts): add sync-tse-rules.mjs to fetch missing typescript-eslint rules/tests as commented Go files
1 parent f12ca6e commit 8644fec

File tree

1 file changed

+224
-0
lines changed

1 file changed

+224
-0
lines changed

scripts/sync-tse-rules.mjs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)