lintcn 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,20 @@
1
- // lintcn remove <name> — delete a rule and its test file from .lintcn/
1
+ // lintcn remove <name> — delete a rule subfolder from .lintcn/
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { requireLintcnDir } from "../paths.js";
5
5
  import { discoverRules } from "../discover.js";
6
6
  export function removeRule(name) {
7
7
  const lintcnDir = requireLintcnDir();
8
- // match by lintcn:name metadata or by filename
9
8
  const rules = discoverRules(lintcnDir);
10
9
  const normalizedName = name.replace(/-/g, '_');
11
10
  const match = rules.find((r) => {
12
- return r.name === name || r.fileName.replace(/\.go$/, '') === normalizedName;
11
+ return r.name === name || r.packageName === normalizedName;
13
12
  });
14
13
  if (!match) {
15
14
  throw new Error(`Rule "${name}" not found. Run \`lintcn list\` to see installed rules.`);
16
15
  }
17
- // delete rule file
18
- const rulePath = path.join(lintcnDir, match.fileName);
19
- fs.rmSync(rulePath);
20
- console.log(`Removed ${match.fileName}`);
21
- // delete test file if exists
22
- const testFileName = match.fileName.replace(/\.go$/, '_test.go');
23
- const testPath = path.join(lintcnDir, testFileName);
24
- if (fs.existsSync(testPath)) {
25
- fs.rmSync(testPath);
26
- console.log(`Removed ${testFileName}`);
27
- }
16
+ // Remove the entire subfolder
17
+ const ruleDir = path.join(lintcnDir, match.packageName);
18
+ fs.rmSync(ruleDir, { recursive: true });
19
+ console.log(`Removed ${match.packageName}/`);
28
20
  }
@@ -1,5 +1,5 @@
1
1
  export interface RuleMetadata {
2
- /** kebab-case rule name from // lintcn:name or derived from filename */
2
+ /** kebab-case rule name from // lintcn:name or derived from folder name */
3
3
  name: string;
4
4
  /** one-line description from // lintcn:description */
5
5
  description: string;
@@ -7,8 +7,8 @@ export interface RuleMetadata {
7
7
  source: string;
8
8
  /** exported Go variable name like NoFloatingPromisesRule */
9
9
  varName: string;
10
- /** filename relative to .lintcn/ */
11
- fileName: string;
10
+ /** Go package name (= subfolder name, e.g. no_floating_promises) */
11
+ packageName: string;
12
12
  }
13
13
  export declare function parseMetadata(content: string): Record<string, string>;
14
14
  export declare function parseRuleVar(content: string): string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,YAAY;IAC3B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAA;IACZ,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAA;CACjB;AAOD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMrE;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhE;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAwC/D"}
1
+ {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAA;IACZ,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf,oEAAoE;IACpE,WAAW,EAAE,MAAM,CAAA;CACpB;AAOD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMrE;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhE;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAgD/D"}
package/dist/discover.js CHANGED
@@ -1,4 +1,5 @@
1
- // Scan .lintcn/*.go files for rule.Rule variables and lintcn: metadata comments.
1
+ // Scan .lintcn/*/ subfolders for rule.Rule variables and lintcn: metadata comments.
2
+ // Each subfolder is a Go package containing one rule with its companions.
2
3
  // Returns structured info about each discovered rule for codegen and list display.
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
@@ -21,31 +22,39 @@ export function discoverRules(lintcnDir) {
21
22
  if (!fs.existsSync(lintcnDir)) {
22
23
  return [];
23
24
  }
24
- const files = fs.readdirSync(lintcnDir).filter((f) => {
25
- return f.endsWith('.go') && !f.endsWith('_test.go');
26
- });
27
25
  const rules = [];
28
- for (const fileName of files) {
29
- const filePath = path.join(lintcnDir, fileName);
30
- const content = fs.readFileSync(filePath, 'utf-8');
31
- const varName = parseRuleVar(content);
32
- if (!varName) {
33
- // warn if file contains rule.Rule but we couldn't parse the var name
34
- if (content.includes('rule.Rule')) {
35
- console.warn(`Warning: ${fileName} contains rule.Rule but no exported var was found. ` +
36
- `Expected pattern: var XxxRule = rule.Rule{`);
37
- }
26
+ const entries = fs.readdirSync(lintcnDir, { withFileTypes: true });
27
+ // Warn about flat .go files that should be in subfolders
28
+ const flatGoFiles = entries.filter((e) => {
29
+ return e.isFile() && e.name.endsWith('.go');
30
+ });
31
+ for (const file of flatGoFiles) {
32
+ const baseName = file.name.replace(/(_test)?\.go$/, '');
33
+ console.error(`Error: ${file.name} is a flat file in .lintcn/ rules must be in subfolders.\n` +
34
+ ` Move it to .lintcn/${baseName}/${file.name} and change the package to "${baseName}".\n`);
35
+ }
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
38
38
  continue;
39
- }
40
- const meta = parseMetadata(content);
41
- const baseName = fileName.replace(/\.go$/, '');
42
- rules.push({
43
- name: meta.name || baseName.replace(/_/g, '-'),
44
- description: meta.description || '',
45
- source: meta.source || '',
46
- varName,
47
- fileName,
39
+ const subDir = path.join(lintcnDir, entry.name);
40
+ const goFiles = fs.readdirSync(subDir).filter((f) => {
41
+ return f.endsWith('.go') && !f.endsWith('_test.go');
48
42
  });
43
+ for (const fileName of goFiles) {
44
+ const filePath = path.join(subDir, fileName);
45
+ const content = fs.readFileSync(filePath, 'utf-8');
46
+ const varName = parseRuleVar(content);
47
+ if (!varName)
48
+ continue;
49
+ const meta = parseMetadata(content);
50
+ rules.push({
51
+ name: meta.name || entry.name.replace(/_/g, '-'),
52
+ description: meta.description || '',
53
+ source: meta.source || '',
54
+ varName,
55
+ packageName: entry.name,
56
+ });
57
+ }
49
58
  }
50
59
  return rules;
51
60
  }
package/dist/hash.d.ts CHANGED
@@ -1,5 +1,11 @@
1
+ /** Compute a deterministic content hash for binary caching.
2
+ * Returns: { short: "a1b2c3d4..." (16 hex), full: "a1b2c3d4..." (64 hex) }
3
+ * The short hash is used for local cache paths, the full hash for remote cache keys. */
1
4
  export declare function computeContentHash({ lintcnDir, tsgolintVersion, }: {
2
5
  lintcnDir: string;
3
6
  tsgolintVersion: string;
4
- }): Promise<string>;
7
+ }): Promise<{
8
+ short: string;
9
+ full: string;
10
+ }>;
5
11
  //# sourceMappingURL=hash.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAaA,wBAAsB,kBAAkB,CAAC,EACvC,SAAS,EACT,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CA8BlB"}
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAiBA;;yFAEyF;AACzF,wBAAsB,kBAAkB,CAAC,EACvC,SAAS,EACT,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA2B3C"}
package/dist/hash.js CHANGED
@@ -1,37 +1,40 @@
1
- // Content hash for binary caching.
2
- // Combines cache schema version, tsgolint version, rule file contents,
3
- // Go version, and platform into a single SHA-256 hash.
1
+ // Content hash for binary caching (local + remote).
2
+ // Combines cache schema version, tsgolint version, platform triplet,
3
+ // and non-test rule file contents into a SHA-256 hash.
4
+ //
5
+ // The hash is deterministic across machines — same rules + same tsgolint
6
+ // version + same platform = same hash. Go version is NOT included because
7
+ // the compiled binary is standalone (no Go runtime dependency).
8
+ //
4
9
  // Bump CACHE_SCHEMA_VERSION when codegen logic changes to invalidate
5
10
  // stale binaries built by older lintcn versions.
6
11
  import crypto from 'node:crypto';
7
12
  import fs from 'node:fs';
8
13
  import path from 'node:path';
9
- import { execAsync } from "./exec.js";
10
- const CACHE_SCHEMA_VERSION = '2';
14
+ const CACHE_SCHEMA_VERSION = '4';
15
+ /** Compute a deterministic content hash for binary caching.
16
+ * Returns: { short: "a1b2c3d4..." (16 hex), full: "a1b2c3d4..." (64 hex) }
17
+ * The short hash is used for local cache paths, the full hash for remote cache keys. */
11
18
  export async function computeContentHash({ lintcnDir, tsgolintVersion, }) {
12
19
  const hash = crypto.createHash('sha256');
13
20
  hash.update(`cache-schema:${CACHE_SCHEMA_VERSION}\n`);
14
21
  hash.update(`tsgolint:${tsgolintVersion}\n`);
15
22
  hash.update(`platform:${process.platform}-${process.arch}\n`);
16
- // add Go version
17
- try {
18
- const { stdout } = await execAsync('go', ['version']);
19
- hash.update(`go:${stdout.trim()}\n`);
23
+ // walk rule subfolders for non-test .go files in sorted order
24
+ const entries = fs.readdirSync(lintcnDir, { withFileTypes: true })
25
+ .filter((e) => { return e.isDirectory() && !e.name.startsWith('.'); })
26
+ .sort((a, b) => { return a.name.localeCompare(b.name); });
27
+ for (const entry of entries) {
28
+ const subDir = path.join(lintcnDir, entry.name);
29
+ const goFiles = fs.readdirSync(subDir)
30
+ .filter((f) => { return f.endsWith('.go') && !f.endsWith('_test.go'); })
31
+ .sort();
32
+ for (const file of goFiles) {
33
+ const content = fs.readFileSync(path.join(subDir, file), 'utf-8');
34
+ hash.update(`file:${entry.name}/${file}\n`);
35
+ hash.update(content);
36
+ }
20
37
  }
21
- catch {
22
- hash.update('go:unknown\n');
23
- }
24
- // add all rule file contents in sorted order
25
- const files = fs
26
- .readdirSync(lintcnDir)
27
- .filter((f) => {
28
- return f.endsWith('.go');
29
- })
30
- .sort();
31
- for (const file of files) {
32
- const content = fs.readFileSync(path.join(lintcnDir, file), 'utf-8');
33
- hash.update(`file:${file}\n`);
34
- hash.update(content);
35
- }
36
- return hash.digest('hex').slice(0, 16);
38
+ const full = hash.digest('hex');
39
+ return { short: full.slice(0, 16), full };
37
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lintcn",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "The shadcn for type-aware TypeScript lint rules. Browse, pick, and copy rules into your project.",
6
6
  "bin": "dist/cli.js",
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { addRule } from './commands/add.ts'
9
9
  import { lint, buildBinary } from './commands/lint.ts'
10
10
  import { listRules } from './commands/list.ts'
11
11
  import { removeRule } from './commands/remove.ts'
12
+ import { clean } from './commands/clean.ts'
12
13
  import { DEFAULT_TSGOLINT_VERSION } from './cache.ts'
13
14
 
14
15
  const require = createRequire(import.meta.url)
@@ -17,11 +18,11 @@ const packageJson = require('../package.json') as { version: string }
17
18
  const cli = goke('lintcn')
18
19
 
19
20
  cli
20
- .command('add <url>', 'Add a rule by URL. Fetches the .go file and copies it into .lintcn/')
21
- .example('# Add a rule from GitHub')
22
- .example('lintcn add https://github.com/user/repo/blob/main/rules/no_floating_promises.go')
23
- .example('# Add from raw URL')
24
- .example('lintcn add https://raw.githubusercontent.com/user/repo/main/rules/no_unused_result.go')
21
+ .command('add <url>', 'Add a rule by GitHub URL. Fetches the whole folder into .lintcn/{rule}/')
22
+ .example('# Add a rule folder')
23
+ .example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
24
+ .example('# Add by file URL (auto-fetches the whole folder)')
25
+ .example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
25
26
  .action(async (url) => {
26
27
  await addRule(url)
27
28
  })
@@ -77,6 +78,12 @@ cli
77
78
  console.log(binaryPath)
78
79
  })
79
80
 
81
+ cli
82
+ .command('clean', 'Remove cached tsgolint source and compiled binaries to free disk space')
83
+ .action(() => {
84
+ clean()
85
+ })
86
+
80
87
  cli.help()
81
88
  cli.version(packageJson.version)
82
89
  cli.parse()
package/src/codegen.ts CHANGED
@@ -129,10 +129,29 @@ go 1.26
129
129
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo)
130
130
  }
131
131
 
132
- /** Generate main.go that imports user rules and calls internal/runner.Run(). */
132
+ /** Sanitize a package name into a valid Go identifier for use as an import alias.
133
+ * Replaces hyphens/dots with underscores, prepends _ if starts with a digit. */
134
+ function toGoAlias(pkg: string): string {
135
+ let alias = pkg.replace(/[^a-zA-Z0-9_]/g, '_')
136
+ if (/^[0-9]/.test(alias)) {
137
+ alias = '_' + alias
138
+ }
139
+ return alias
140
+ }
141
+
142
+ /** Generate main.go that imports user rules and calls internal/runner.Run().
143
+ * Each rule subfolder is its own Go package, imported by package name. */
133
144
  function generateMainGo(rules: RuleMetadata[]): string {
145
+ // Deduplicate imports by package name (in case a subfolder has multiple rules)
146
+ const uniquePackages = [...new Set(rules.map((r) => { return r.packageName }))]
147
+ const imports = uniquePackages.map((pkg) => {
148
+ const alias = toGoAlias(pkg)
149
+ return `\t${alias} "${TSGOLINT_MODULE}/lintcn-rules/${pkg}"`
150
+ }).join('\n')
151
+
134
152
  const ruleEntries = rules.map((r) => {
135
- return `\t\tlintcn.${r.varName},`
153
+ const alias = toGoAlias(r.packageName)
154
+ return `\t\t${alias}.${r.varName},`
136
155
  }).join('\n')
137
156
 
138
157
  return `// Code generated by lintcn. DO NOT EDIT.
@@ -143,7 +162,7 @@ import (
143
162
 
144
163
  \t"${TSGOLINT_MODULE}/internal/rule"
145
164
  \t"${TSGOLINT_MODULE}/internal/runner"
146
- \tlintcn "${TSGOLINT_MODULE}/lintcn-rules"
165
+ ${imports}
147
166
  )
148
167
 
149
168
  func main() {
@@ -1,69 +1,110 @@
1
- // lintcn add <url> — fetch a .go rule file by URL and copy into .lintcn/
2
- // Also tries to fetch matching _test.go file from the same directory.
3
- // Normalizes GitHub blob URLs to raw URLs automatically.
1
+ // lintcn add <url> — fetch a rule folder by URL and copy into .lintcn/{rule_name}/
2
+ // Supports GitHub folder URLs (/tree/) and file URLs (/blob/).
3
+ // For file URLs, auto-detects the parent folder and fetches all sibling files.
4
+ // Uses GitHub API to list folder contents.
4
5
 
5
6
  import fs from 'node:fs'
6
7
  import path from 'node:path'
8
+ import { execSync } from 'node:child_process'
7
9
  import { getLintcnDir } from '../paths.ts'
8
10
  import { generateEditorGoFiles } from '../codegen.ts'
9
11
  import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from '../cache.ts'
10
12
 
11
- /** Convert GitHub blob URLs to raw.githubusercontent.com.
12
- * Handles branch names containing slashes (e.g. feature/x) by splitting
13
- * on /blob/ then finding the file path from the end (must end in .go). */
14
- function normalizeGithubUrl(url: string): string {
15
- const blobSplit = url.match(/^(https?:\/\/github\.com\/[^/]+\/[^/]+)\/blob\/(.+)$/)
16
- if (!blobSplit) {
17
- return url
13
+ interface ParsedGitHubUrl {
14
+ owner: string
15
+ repo: string
16
+ ref: string
17
+ /** Path to the directory containing the rule files */
18
+ dirPath: string
19
+ /** Set when URL points to a specific file (not a folder) */
20
+ fileName?: string
21
+ }
22
+
23
+ /** Parse GitHub blob/tree/raw URLs into components.
24
+ * Ref is assumed to be the first path component after blob/tree —
25
+ * branch names with slashes (e.g. feature/foo) are not supported. */
26
+ function parseGitHubUrl(url: string): ParsedGitHubUrl | null {
27
+ // GitHub blob URLs: github.com/owner/repo/blob/ref/path/to/file.go
28
+ let match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/)
29
+ if (match) {
30
+ const [, owner, repo, ref, filePath] = match
31
+ return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) }
18
32
  }
19
33
 
20
- const [, repoUrl, refAndPath] = blobSplit
21
- // repoUrl = "https://github.com/owner/repo"
22
- // refAndPath = "feature/x/rules/my_rule.go" or "main/rules/my_rule.go"
34
+ // GitHub tree URLs: github.com/owner/repo/tree/ref/path/to/folder
35
+ match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/)
36
+ if (match) {
37
+ const [, owner, repo, ref, dirPath] = match
38
+ return { owner, repo, ref, dirPath }
39
+ }
23
40
 
24
- // Extract owner/repo from repoUrl
25
- const repoMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)$/)
26
- if (!repoMatch) {
27
- return url
41
+ // raw.githubusercontent.com URLs
42
+ match = url.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/)
43
+ if (match) {
44
+ const [, owner, repo, ref, filePath] = match
45
+ return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) }
28
46
  }
29
- const [, owner, repo] = repoMatch
30
47
 
31
- // For raw.githubusercontent.com, the format is owner/repo/ref/path.
32
- // We can pass refAndPath directly since GitHub resolves it.
33
- return `https://raw.githubusercontent.com/${owner}/${repo}/${refAndPath}`
48
+ return null
34
49
  }
35
50
 
36
- function deriveTestUrl(rawUrl: string): string {
37
- return rawUrl.replace(/\.go$/, '_test.go')
51
+ interface GitHubContentItem {
52
+ name: string
53
+ download_url: string | null
54
+ type: 'file' | 'dir'
38
55
  }
39
56
 
40
- async function fetchFile(url: string): Promise<string> {
41
- const response = await fetch(url)
42
- if (!response.ok) {
43
- throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
57
+ /** Get a GitHub auth token from gh CLI, GITHUB_TOKEN env, or return undefined. */
58
+ function getGitHubToken(): string | undefined {
59
+ if (process.env.GITHUB_TOKEN) {
60
+ return process.env.GITHUB_TOKEN
44
61
  }
45
- return response.text()
46
- }
47
-
48
- async function tryFetchFile(url: string): Promise<string | null> {
62
+ // Try gh CLI token (synchronous to keep it simple)
49
63
  try {
50
- return await fetchFile(url)
64
+ return execSync('gh auth token', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || undefined
51
65
  } catch {
52
- return null
66
+ return undefined
53
67
  }
54
68
  }
55
69
 
56
- function rewritePackageName(content: string): string {
57
- // Rewrite first package declaration to package lintcn.
58
- // Only matches before the first import or func to avoid touching comments.
59
- return content.replace(/^package\s+\w+/m, 'package lintcn')
70
+ /** List files in a GitHub directory via the Contents API. */
71
+ async function listGitHubFolder(owner: string, repo: string, dirPath: string, ref: string): Promise<GitHubContentItem[]> {
72
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`
73
+ const headers: Record<string, string> = {
74
+ 'Accept': 'application/vnd.github.v3+json',
75
+ 'User-Agent': 'lintcn',
76
+ }
77
+ const token = getGitHubToken()
78
+ if (token) {
79
+ headers['Authorization'] = `Bearer ${token}`
80
+ }
81
+ const response = await fetch(apiUrl, { headers })
82
+
83
+ if (!response.ok) {
84
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`)
85
+ }
86
+
87
+ const data = await response.json()
88
+ if (!Array.isArray(data)) {
89
+ throw new Error(`Expected a directory listing from GitHub API but got a single file.\n ${apiUrl}`)
90
+ }
91
+
92
+ return data as GitHubContentItem[]
93
+ }
94
+
95
+ async function fetchFile(url: string): Promise<string> {
96
+ const response = await fetch(url)
97
+ if (!response.ok) {
98
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
99
+ }
100
+ return response.text()
60
101
  }
61
102
 
62
103
  function ensureSourceComment(content: string, sourceUrl: string): string {
63
104
  if (content.includes('// lintcn:source')) {
64
105
  return content
65
106
  }
66
- // Insert source comment after the first lintcn: comment block, or at the very top
107
+ // Insert source comment after any existing lintcn: comment block, or at the very top
67
108
  const lines = content.split('\n')
68
109
  let insertIndex = 0
69
110
  for (let i = 0; i < lines.length; i++) {
@@ -78,59 +119,76 @@ function ensureSourceComment(content: string, sourceUrl: string): string {
78
119
  }
79
120
 
80
121
  export async function addRule(url: string): Promise<void> {
81
- const rawUrl = normalizeGithubUrl(url)
122
+ const parsed = parseGitHubUrl(url)
123
+ if (!parsed) {
124
+ throw new Error(
125
+ 'Only GitHub URLs are supported. Pass a /blob/ (file) or /tree/ (folder) URL.\n' +
126
+ 'Example: lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises',
127
+ )
128
+ }
129
+
130
+ const { owner, repo, ref, dirPath } = parsed
131
+ const folderName = path.posix.basename(dirPath)
132
+
133
+ console.log(`Fetching ${owner}/${repo}/${dirPath}...`)
134
+ const items = await listGitHubFolder(owner, repo, dirPath, ref)
82
135
 
83
- console.log(`Fetching ${rawUrl}...`)
84
- const content = await fetchFile(rawUrl)
136
+ // Filter for .go and .json files
137
+ const filesToFetch = items.filter((item) => {
138
+ return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'))
139
+ })
85
140
 
86
- // validate it looks like a Go file with a rule
87
- if (!content.includes('rule.Rule')) {
88
- console.warn('Warning: no rule.Rule reference found in this file. Are you sure this is a tsgolint rule?')
141
+ if (filesToFetch.length === 0) {
142
+ throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`)
89
143
  }
90
144
 
91
- // derive filename from URL
92
- const urlPath = new URL(rawUrl).pathname
93
- const fileName = path.basename(urlPath)
94
- if (!fileName.endsWith('.go')) {
95
- throw new Error(`URL must point to a .go file, got: ${fileName}`)
145
+ // Warn if this doesn't look like a single-rule folder (too many main .go files)
146
+ const mainGoFiles = filesToFetch.filter((f) => {
147
+ return f.name.endsWith('.go') && !f.name.endsWith('_test.go') && f.name !== 'options.go'
148
+ })
149
+ if (mainGoFiles.length > 3) {
150
+ console.warn(
151
+ `Warning: folder has ${mainGoFiles.length} non-test .go files. ` +
152
+ `This may be a directory of multiple rules — consider using a more specific URL.`,
153
+ )
96
154
  }
97
155
 
98
156
  const lintcnDir = getLintcnDir()
99
- fs.mkdirSync(lintcnDir, { recursive: true })
157
+ const ruleDir = path.join(lintcnDir, folderName)
100
158
 
101
- // write the rule file
102
- const filePath = path.join(lintcnDir, fileName)
103
- if (fs.existsSync(filePath)) {
104
- console.log(`Overwriting existing ${fileName}`)
159
+ // Clean existing rule folder if it exists
160
+ if (fs.existsSync(ruleDir)) {
161
+ fs.rmSync(ruleDir, { recursive: true })
162
+ console.log(`Overwriting existing ${folderName}/`)
105
163
  }
106
164
 
107
- let processed = rewritePackageName(content)
108
- processed = ensureSourceComment(processed, url)
109
- fs.writeFileSync(filePath, processed)
110
- console.log(`Added ${fileName}`)
165
+ fs.mkdirSync(ruleDir, { recursive: true })
111
166
 
112
- // try to fetch matching test file
113
- const testUrl = deriveTestUrl(rawUrl)
114
- const testContent = await tryFetchFile(testUrl)
115
- if (testContent) {
116
- const testFileName = fileName.replace(/\.go$/, '_test.go')
117
- const testProcessed = rewritePackageName(testContent)
118
- fs.writeFileSync(path.join(lintcnDir, testFileName), testProcessed)
119
- console.log(`Added ${testFileName}`)
167
+ // Fetch and write all files
168
+ for (const item of filesToFetch) {
169
+ let content = await fetchFile(item.download_url!)
170
+
171
+ // Add lintcn:source comment to the main rule file (same name as folder)
172
+ if (item.name === `${folderName}.go`) {
173
+ content = ensureSourceComment(content, url)
174
+ }
175
+
176
+ fs.writeFileSync(path.join(ruleDir, item.name), content)
177
+ console.log(` ${item.name}`)
120
178
  }
121
179
 
122
- // ensure .tsgolint source is available and generate editor support files
180
+ console.log(`Added ${folderName}/ (${filesToFetch.length} files)`)
181
+
182
+ // Ensure tsgolint source is available
123
183
  const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION)
124
184
 
125
- // create .tsgolint symlink inside .lintcn for gopls.
126
- // Use lstatSync to detect broken symlinks (existsSync returns false for broken links)
185
+ // Create/refresh .tsgolint symlink for gopls
127
186
  const tsgolintLink = path.join(lintcnDir, '.tsgolint')
128
187
  try {
129
188
  fs.lstatSync(tsgolintLink)
130
- // exists (possibly broken) — remove and recreate
131
189
  fs.rmSync(tsgolintLink, { force: true })
132
190
  } catch {
133
- // doesn't exist at all
191
+ // doesn't exist
134
192
  }
135
193
  fs.symlinkSync(tsgolintDir, tsgolintLink)
136
194
 
@@ -0,0 +1,48 @@
1
+ // lintcn clean — remove cached tsgolint source and compiled binaries.
2
+ // Frees disk space from old versions that accumulate over time.
3
+
4
+ import fs from 'node:fs'
5
+ import { getCacheDir } from '../cache.ts'
6
+
7
+ export function clean(): void {
8
+ const cacheDir = getCacheDir()
9
+
10
+ if (!fs.existsSync(cacheDir)) {
11
+ console.log('No cache to clean')
12
+ return
13
+ }
14
+
15
+ const stats = getCacheStats(cacheDir)
16
+ fs.rmSync(cacheDir, { recursive: true })
17
+ console.log(`Removed ${cacheDir} (${formatBytes(stats.totalBytes)})`)
18
+ }
19
+
20
+ function getCacheStats(dir: string): { totalBytes: number } {
21
+ let totalBytes = 0
22
+ const walk = (d: string): void => {
23
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
24
+ const fullPath = `${d}/${entry.name}`
25
+ if (entry.isDirectory()) {
26
+ walk(fullPath)
27
+ } else {
28
+ totalBytes += fs.statSync(fullPath).size
29
+ }
30
+ }
31
+ }
32
+ try {
33
+ walk(dir)
34
+ } catch {
35
+ // ignore errors during stat
36
+ }
37
+ return { totalBytes }
38
+ }
39
+
40
+ function formatBytes(bytes: number): string {
41
+ if (bytes < 1024) {
42
+ return `${bytes}B`
43
+ }
44
+ if (bytes < 1024 * 1024) {
45
+ return `${(bytes / 1024).toFixed(0)}KB`
46
+ }
47
+ return `${(bytes / (1024 * 1024)).toFixed(0)}MB`
48
+ }