lintcn 0.5.0 → 0.7.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,69 +1,155 @@
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
+ /** Branch/tag/commit. Undefined for bare repo URLs — resolve via API. */
17
+ ref: string | undefined
18
+ /** Path to the directory containing the rule files. Empty string for repo root. */
19
+ dirPath: string
20
+ /** Set when URL points to a specific file (not a folder) */
21
+ fileName?: string
22
+ }
23
+
24
+ /** Parse any GitHub URL into components.
25
+ * Supports: bare repo, /tree/ folders, /blob/ files, raw.githubusercontent.com.
26
+ * Ref is the first path component after blob/tree — branch names with slashes
27
+ * (e.g. feature/foo) are not supported. */
28
+ function parseGitHubUrl(url: string): ParsedGitHubUrl | null {
29
+ let hostname: string
30
+ let segments: string[]
31
+ try {
32
+ const u = new URL(url)
33
+ hostname = u.hostname
34
+ segments = u.pathname.replace(/\/$/, '').split('/').filter(Boolean)
35
+ } catch {
36
+ return null
37
+ }
38
+
39
+ // raw.githubusercontent.com/owner/repo/ref/path/to/file
40
+ if (hostname === 'raw.githubusercontent.com') {
41
+ if (segments.length < 4) return null
42
+ const [owner, repo, ref, ...rest] = segments
43
+ const filePath = rest.join('/')
44
+ return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) }
45
+ }
46
+
47
+ if (hostname !== 'github.com') return null
48
+ if (segments.length < 2) return null
49
+
50
+ const [owner, repo, kind, ref, ...rest] = segments
51
+
52
+ // Bare repo URL: github.com/owner/repo
53
+ if (!kind) {
54
+ return { owner, repo, ref: undefined, dirPath: '' }
18
55
  }
19
56
 
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"
57
+ const subPath = rest.join('/')
23
58
 
24
- // Extract owner/repo from repoUrl
25
- const repoMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)$/)
26
- if (!repoMatch) {
27
- return url
59
+ if (kind === 'tree') {
60
+ if (!ref || !subPath) return null
61
+ return { owner, repo, ref, dirPath: subPath }
28
62
  }
29
- const [, owner, repo] = repoMatch
30
63
 
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}`
64
+ if (kind === 'blob') {
65
+ if (!ref || !subPath) return null
66
+ return { owner, repo, ref, dirPath: path.posix.dirname(subPath), fileName: path.posix.basename(subPath) }
67
+ }
68
+
69
+ return null
34
70
  }
35
71
 
36
- function deriveTestUrl(rawUrl: string): string {
37
- return rawUrl.replace(/\.go$/, '_test.go')
72
+ interface GitHubContentItem {
73
+ name: string
74
+ download_url: string | null
75
+ type: 'file' | 'dir'
38
76
  }
39
77
 
40
- async function fetchFile(url: string): Promise<string> {
41
- const response = await fetch(url)
78
+ /** Get a GitHub auth token from gh CLI, GITHUB_TOKEN env, or return undefined. */
79
+ function getGitHubToken(): string | undefined {
80
+ if (process.env.GITHUB_TOKEN) {
81
+ return process.env.GITHUB_TOKEN
82
+ }
83
+ // Try gh CLI token (synchronous to keep it simple)
84
+ try {
85
+ return execSync('gh auth token', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || undefined
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+
91
+ /** Resolve the default branch for a repo (e.g. "main", "master"). */
92
+ async function resolveDefaultBranch(owner: string, repo: string): Promise<string> {
93
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}`
94
+ const headers: Record<string, string> = {
95
+ 'Accept': 'application/vnd.github.v3+json',
96
+ 'User-Agent': 'lintcn',
97
+ }
98
+ const token = getGitHubToken()
99
+ if (token) {
100
+ headers['Authorization'] = `Bearer ${token}`
101
+ }
102
+ const response = await fetch(apiUrl, { headers })
42
103
  if (!response.ok) {
43
- throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
104
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`)
44
105
  }
45
- return response.text()
106
+ const data = (await response.json()) as { default_branch: string }
107
+ return data.default_branch
46
108
  }
47
109
 
48
- async function tryFetchFile(url: string): Promise<string | null> {
49
- try {
50
- return await fetchFile(url)
51
- } catch {
52
- return null
110
+ /** Files/dirs in .lintcn/ that are generated and should not be treated as rule folders. */
111
+ const LINTCN_GENERATED = new Set([
112
+ '.tsgolint', '.gitignore', 'go.work', 'go.work.sum', 'go.mod', 'go.sum',
113
+ ])
114
+
115
+ /** List files in a GitHub directory via the Contents API. */
116
+ async function listGitHubFolder(owner: string, repo: string, dirPath: string, ref: string): Promise<GitHubContentItem[]> {
117
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`
118
+ const headers: Record<string, string> = {
119
+ 'Accept': 'application/vnd.github.v3+json',
120
+ 'User-Agent': 'lintcn',
121
+ }
122
+ const token = getGitHubToken()
123
+ if (token) {
124
+ headers['Authorization'] = `Bearer ${token}`
53
125
  }
126
+ const response = await fetch(apiUrl, { headers })
127
+
128
+ if (!response.ok) {
129
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`)
130
+ }
131
+
132
+ const data = await response.json()
133
+ if (!Array.isArray(data)) {
134
+ throw new Error(`Expected a directory listing from GitHub API but got a single file.\n ${apiUrl}`)
135
+ }
136
+
137
+ return data as GitHubContentItem[]
54
138
  }
55
139
 
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')
140
+ async function fetchFile(url: string): Promise<string> {
141
+ const response = await fetch(url)
142
+ if (!response.ok) {
143
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
144
+ }
145
+ return response.text()
60
146
  }
61
147
 
62
148
  function ensureSourceComment(content: string, sourceUrl: string): string {
63
149
  if (content.includes('// lintcn:source')) {
64
150
  return content
65
151
  }
66
- // Insert source comment after the first lintcn: comment block, or at the very top
152
+ // Insert source comment after any existing lintcn: comment block, or at the very top
67
153
  const lines = content.split('\n')
68
154
  let insertIndex = 0
69
155
  for (let i = 0; i < lines.length; i++) {
@@ -77,63 +163,174 @@ function ensureSourceComment(content: string, sourceUrl: string): string {
77
163
  return lines.join('\n')
78
164
  }
79
165
 
80
- export async function addRule(url: string): Promise<void> {
81
- const rawUrl = normalizeGithubUrl(url)
166
+ /** Download a single rule folder into .lintcn/{folderName}/.
167
+ * Overwrites existing folder if present.
168
+ * Returns true if the rule was added, false if skipped (no .go files). */
169
+ async function downloadSingleRule(
170
+ owner: string, repo: string, ref: string, dirPath: string,
171
+ lintcnDir: string, sourceUrl: string,
172
+ ): Promise<boolean> {
173
+ const folderName = path.posix.basename(dirPath)
174
+ const items = await listGitHubFolder(owner, repo, dirPath, ref)
82
175
 
83
- console.log(`Fetching ${rawUrl}...`)
84
- const content = await fetchFile(rawUrl)
176
+ const filesToFetch = items.filter((item) => {
177
+ return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'))
178
+ })
85
179
 
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?')
180
+ if (filesToFetch.length === 0) {
181
+ console.warn(` Skipping ${folderName}/ — no .go files found`)
182
+ return false
89
183
  }
90
184
 
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}`)
185
+ const ruleDir = path.join(lintcnDir, folderName)
186
+
187
+ if (fs.existsSync(ruleDir)) {
188
+ fs.rmSync(ruleDir, { recursive: true })
189
+ console.log(` Overwriting existing ${folderName}/`)
96
190
  }
97
191
 
98
- const lintcnDir = getLintcnDir()
99
- fs.mkdirSync(lintcnDir, { recursive: true })
192
+ fs.mkdirSync(ruleDir, { recursive: true })
100
193
 
101
- // write the rule file
102
- const filePath = path.join(lintcnDir, fileName)
103
- if (fs.existsSync(filePath)) {
104
- console.log(`Overwriting existing ${fileName}`)
105
- }
194
+ for (const item of filesToFetch) {
195
+ let content = await fetchFile(item.download_url!)
106
196
 
107
- let processed = rewritePackageName(content)
108
- processed = ensureSourceComment(processed, url)
109
- fs.writeFileSync(filePath, processed)
110
- console.log(`Added ${fileName}`)
197
+ if (item.name === `${folderName}.go`) {
198
+ content = ensureSourceComment(content, sourceUrl)
199
+ }
111
200
 
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}`)
201
+ fs.writeFileSync(path.join(ruleDir, item.name), content)
202
+ console.log(` ${item.name}`)
120
203
  }
121
204
 
122
- // ensure .tsgolint source is available and generate editor support files
205
+ console.log(` Added ${folderName}/ (${filesToFetch.length} files)`)
206
+ return true
207
+ }
208
+
209
+ /** Ensure tsgolint source, refresh symlink, regenerate go.work/go.mod. */
210
+ async function finalizeLintcnDir(lintcnDir: string): Promise<void> {
123
211
  const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION)
124
212
 
125
- // create .tsgolint symlink inside .lintcn for gopls.
126
- // Use lstatSync to detect broken symlinks (existsSync returns false for broken links)
127
213
  const tsgolintLink = path.join(lintcnDir, '.tsgolint')
128
214
  try {
129
215
  fs.lstatSync(tsgolintLink)
130
- // exists (possibly broken) — remove and recreate
131
216
  fs.rmSync(tsgolintLink, { force: true })
132
217
  } catch {
133
- // doesn't exist at all
218
+ // doesn't exist
134
219
  }
135
220
  fs.symlinkSync(tsgolintDir, tsgolintLink)
136
221
 
137
222
  generateEditorGoFiles(lintcnDir)
138
223
  console.log('Editor support files generated (go.work, go.mod)')
139
224
  }
225
+
226
+ /** Download all rule subfolders from a remote .lintcn/ directory.
227
+ * Each subfolder is treated as a separate rule. Local rules not present
228
+ * in the remote are preserved (merge, not replace). */
229
+ async function addLintcnFolder(
230
+ owner: string, repo: string, ref: string, lintcnPath: string, sourceUrl: string,
231
+ ): Promise<void> {
232
+ console.log(`Fetching .lintcn/ from ${owner}/${repo}...`)
233
+ const items = await listGitHubFolder(owner, repo, lintcnPath, ref)
234
+
235
+ const ruleDirs = items.filter((item) => {
236
+ return item.type === 'dir' && !LINTCN_GENERATED.has(item.name) && !item.name.startsWith('.')
237
+ })
238
+
239
+ if (ruleDirs.length === 0) {
240
+ throw new Error(`No rule folders found in ${lintcnPath}. Is this a .lintcn/ directory?`)
241
+ }
242
+
243
+ console.log(`Found ${ruleDirs.length} rule(s)`)
244
+
245
+ const lintcnDir = getLintcnDir()
246
+
247
+ let added = 0
248
+ for (const dir of ruleDirs) {
249
+ const ruleDirPath = lintcnPath ? `${lintcnPath}/${dir.name}` : dir.name
250
+ const ok = await downloadSingleRule(owner, repo, ref, ruleDirPath, lintcnDir, sourceUrl)
251
+ if (ok) added++
252
+ }
253
+
254
+ if (added === 0) {
255
+ throw new Error(`No rule folders with .go files found in ${lintcnPath}.`)
256
+ }
257
+
258
+ await finalizeLintcnDir(lintcnDir)
259
+ console.log(`\nDone — added ${added} rule(s) from ${owner}/${repo}`)
260
+ }
261
+
262
+ export async function addRule(url: string): Promise<void> {
263
+ const parsed = parseGitHubUrl(url)
264
+ if (!parsed) {
265
+ throw new Error(
266
+ 'Only GitHub URLs are supported.\n' +
267
+ 'Examples:\n' +
268
+ ' lintcn add https://github.com/someone/their-project\n' +
269
+ ' lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises',
270
+ )
271
+ }
272
+
273
+ const { owner, repo, fileName } = parsed
274
+ let { ref, dirPath } = parsed
275
+
276
+ // Bare repo URL — resolve default branch and look for .lintcn/ at root
277
+ if (ref === undefined) {
278
+ ref = await resolveDefaultBranch(owner, repo)
279
+ console.log(`Resolved default branch: ${ref}`)
280
+
281
+ const rootItems = await listGitHubFolder(owner, repo, '', ref)
282
+ const lintcnEntry = rootItems.find((item) => item.type === 'dir' && item.name === '.lintcn')
283
+ if (!lintcnEntry) {
284
+ throw new Error(
285
+ `No .lintcn/ directory found in ${owner}/${repo}.\n` +
286
+ 'The repo needs a .lintcn/ folder with rule subfolders.',
287
+ )
288
+ }
289
+
290
+ await addLintcnFolder(owner, repo, ref, '.lintcn', url)
291
+ return
292
+ }
293
+
294
+ // For blob/raw URLs, dirPath already points at the parent folder
295
+ // For tree URLs, dirPath is the folder itself — but it might be a .lintcn/ collection
296
+ if (!fileName) {
297
+ const items = await listGitHubFolder(owner, repo, dirPath, ref)
298
+ const hasGoFiles = items.some((i) => i.type === 'file' && i.name.endsWith('.go'))
299
+ const hasCandidateSubdirs = items.some((i) => {
300
+ return i.type === 'dir' && !LINTCN_GENERATED.has(i.name) && !i.name.startsWith('.')
301
+ })
302
+ const isLintcnDir = path.posix.basename(dirPath) === '.lintcn'
303
+
304
+ // Collection if: explicitly .lintcn/, or has subdirs but no .go files at root.
305
+ // A single rule folder with testdata/ subdirs won't be misdetected because
306
+ // it also has .go files at root.
307
+ if (isLintcnDir || (!hasGoFiles && hasCandidateSubdirs)) {
308
+ await addLintcnFolder(owner, repo, ref, dirPath, url)
309
+ return
310
+ }
311
+ }
312
+
313
+ // Single rule — download one folder
314
+ const folderName = path.posix.basename(dirPath)
315
+
316
+ console.log(`Fetching ${owner}/${repo}/${dirPath}...`)
317
+ const lintcnDir = getLintcnDir()
318
+
319
+ // Warn if this doesn't look like a single-rule folder (too many main .go files)
320
+ const items = await listGitHubFolder(owner, repo, dirPath, ref)
321
+ const mainGoFiles = items.filter((f) => {
322
+ return f.type === 'file' && f.name.endsWith('.go') && !f.name.endsWith('_test.go') && f.name !== 'options.go'
323
+ })
324
+ if (mainGoFiles.length > 3) {
325
+ console.warn(
326
+ `Warning: folder has ${mainGoFiles.length} non-test .go files. ` +
327
+ `This may be a directory of multiple rules — consider using a more specific URL.`,
328
+ )
329
+ }
330
+
331
+ const ok = await downloadSingleRule(owner, repo, ref, dirPath, lintcnDir, url)
332
+ if (!ok) {
333
+ throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`)
334
+ }
335
+ await finalizeLintcnDir(lintcnDir)
336
+ }
@@ -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
+ }
@@ -2,10 +2,11 @@
2
2
  // Handles Go workspace generation, compilation with caching, and execution.
3
3
 
4
4
  import fs from 'node:fs'
5
+ import path from 'node:path'
5
6
  import { spawn } from 'node:child_process'
6
7
  import { requireLintcnDir } from '../paths.ts'
7
- import { discoverRules } from '../discover.ts'
8
- import { generateBuildWorkspace } from '../codegen.ts'
8
+ import { discoverRules, type RuleMetadata } from '../discover.ts'
9
+ import { generateBuildWorkspace, generateEditorGoFiles } from '../codegen.ts'
9
10
  import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
10
11
  import { computeContentHash } from '../hash.ts'
11
12
  import { execAsync } from '../exec.ts'
@@ -44,7 +45,7 @@ export async function buildBinary({
44
45
  const tsgolintDir = await ensureTsgolintSource(tsgolintVersion)
45
46
 
46
47
  // compute content hash
47
- const contentHash = await computeContentHash({
48
+ const { short: contentHash } = await computeContentHash({
48
49
  lintcnDir,
49
50
  tsgolintVersion,
50
51
  })
@@ -55,6 +56,9 @@ export async function buildBinary({
55
56
  return getBinaryPath(contentHash)
56
57
  }
57
58
 
59
+ // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
60
+ generateEditorGoFiles(lintcnDir)
61
+
58
62
  // generate build workspace (per-hash dir to avoid races between concurrent processes)
59
63
  const buildDir = getBuildDir(contentHash)
60
64
  console.log('Generating build workspace...')
@@ -70,10 +74,23 @@ export async function buildBinary({
70
74
  fs.mkdirSync(binDir, { recursive: true })
71
75
  const binaryPath = getBinaryPath(contentHash)
72
76
 
73
- console.log('Compiling custom tsgolint binary...')
74
- await execAsync('go', ['build', '-o', binaryPath, './wrapper'], {
77
+ // Check if any lintcn binary has been built before — if not, this is a cold
78
+ // build that compiles the full tsgolint + typescript-go dependency tree.
79
+ const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : []
80
+ if (existingBins.length === 0) {
81
+ console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...')
82
+ console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).')
83
+ } else {
84
+ console.log('Compiling custom tsgolint binary...')
85
+ }
86
+
87
+ const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', binaryPath, './wrapper'], {
75
88
  cwd: buildDir,
89
+ stdio: 'inherit',
76
90
  })
91
+ if (buildExitCode !== 0) {
92
+ throw new Error(`Go compilation failed (exit code ${buildExitCode})`)
93
+ }
77
94
 
78
95
  console.log('Build complete')
79
96
  return binaryPath
@@ -83,16 +100,36 @@ export async function lint({
83
100
  rebuild,
84
101
  tsgolintVersion,
85
102
  passthroughArgs,
103
+ allWarnings,
86
104
  }: {
87
105
  rebuild: boolean
88
106
  tsgolintVersion: string
89
107
  passthroughArgs: string[]
108
+ allWarnings: boolean
90
109
  }): Promise<number> {
91
110
  const binaryPath = await buildBinary({ rebuild, tsgolintVersion })
92
111
 
93
- // run the binary with passthrough args, inheriting stdio
112
+ // Discover rules to inject --warn flags for warning-severity rules.
113
+ // buildBinary already discovered rules for compilation, but we need the
114
+ // metadata here to know which rules are warnings at runtime.
115
+ const lintcnDir = requireLintcnDir()
116
+ const rules = discoverRules(lintcnDir)
117
+ const warnArgs = buildWarnArgs(rules)
118
+
119
+ // By default, limit warnings to git-changed files so they don't flood
120
+ // the output in large codebases. --all-warnings bypasses this filter.
121
+ const hasWarnRules = rules.some((r) => r.severity === 'warn')
122
+ let warnFileArgs: string[] = []
123
+ if (hasWarnRules && allWarnings) {
124
+ warnFileArgs = ['--all-warnings']
125
+ } else if (hasWarnRules) {
126
+ warnFileArgs = await buildWarnFileArgs()
127
+ }
128
+
129
+ // run the binary with --warn + --warn-file/--all-warnings flags + passthrough args
130
+ const allArgs = [...warnArgs, ...warnFileArgs, ...passthroughArgs]
94
131
  return new Promise((resolve) => {
95
- const proc = spawn(binaryPath, passthroughArgs, {
132
+ const proc = spawn(binaryPath, allArgs, {
96
133
  stdio: 'inherit',
97
134
  })
98
135
 
@@ -106,3 +143,59 @@ export async function lint({
106
143
  })
107
144
  })
108
145
  }
146
+
147
+ /** Build --warn flags for rules with severity 'warn'.
148
+ * Uses goRuleName (parsed from Go source) to match the runtime name
149
+ * that tsgolint uses in diagnostics, avoiding silent mismatches. */
150
+ function buildWarnArgs(rules: RuleMetadata[]): string[] {
151
+ const args: string[] = []
152
+ for (const rule of rules) {
153
+ if (rule.severity === 'warn') {
154
+ args.push('--warn', rule.goRuleName)
155
+ }
156
+ }
157
+ return args
158
+ }
159
+
160
+ /** Get git-changed files and build --warn-file flags so warnings only
161
+ * appear for new/modified code. Returns [] if git is unavailable or not
162
+ * a git repo — the runner will then show no warnings (safe default).
163
+ * Linting must never crash from this. */
164
+ async function buildWarnFileArgs(): Promise<string[]> {
165
+ try {
166
+ // Get git repo root to resolve relative paths to absolute.
167
+ const topLevelResult = await execAsync('git', ['rev-parse', '--show-toplevel'], { stdio: 'pipe' }).catch(() => null)
168
+ if (!topLevelResult) return []
169
+ const repoRoot = topLevelResult.stdout.trim()
170
+
171
+ // Changed files (staged + unstaged vs HEAD)
172
+ const diffResult = await execAsync('git', ['diff', '--name-only', 'HEAD'], { stdio: 'pipe' }).catch(() => null)
173
+ // Untracked files (new files not yet committed)
174
+ const untrackedResult = await execAsync('git', ['ls-files', '--others', '--exclude-standard'], { stdio: 'pipe' }).catch(() => null)
175
+
176
+ const files = new Set<string>()
177
+
178
+ for (const result of [diffResult, untrackedResult]) {
179
+ if (!result) continue
180
+ for (const line of result.stdout.split('\n')) {
181
+ const trimmed = line.trim()
182
+ if (trimmed) {
183
+ // Resolve to absolute path so it matches SourceFile.FileName() in the runner.
184
+ files.add(path.resolve(repoRoot, trimmed))
185
+ }
186
+ }
187
+ }
188
+
189
+ // No changed files → no --warn-file flags → runner shows no warnings (clean tree)
190
+ if (files.size === 0) return []
191
+
192
+ const args: string[] = []
193
+ for (const file of files) {
194
+ args.push('--warn-file', file)
195
+ }
196
+ return args
197
+ } catch {
198
+ // git not installed, not a repo, or any other failure — no warnings shown
199
+ return []
200
+ }
201
+ }
@@ -20,10 +20,13 @@ export function listRules(): void {
20
20
 
21
21
  console.log('Installed rules:\n')
22
22
 
23
- const maxNameLen = Math.max(...rules.map((r) => { return r.name.length }))
23
+ const maxNameLen = Math.max(...rules.map((r) => {
24
+ return r.name.length + (r.severity === 'warn' ? 7 : 0) // ' (warn)' = 7 chars
25
+ }))
24
26
 
25
27
  for (const rule of rules) {
26
- const name = rule.name.padEnd(maxNameLen + 2)
28
+ const suffix = rule.severity === 'warn' ? ' (warn)' : ''
29
+ const name = (rule.name + suffix).padEnd(maxNameLen + 2)
27
30
  const desc = rule.description || '(no description)'
28
31
  console.log(` ${name}${desc}`)
29
32
  }
@@ -1,4 +1,4 @@
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
 
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
@@ -7,13 +7,11 @@ import { discoverRules } from '../discover.ts'
7
7
 
8
8
  export function removeRule(name: string): void {
9
9
  const lintcnDir = requireLintcnDir()
10
-
11
- // match by lintcn:name metadata or by filename
12
10
  const rules = discoverRules(lintcnDir)
13
11
  const normalizedName = name.replace(/-/g, '_')
14
12
 
15
13
  const match = rules.find((r) => {
16
- return r.name === name || r.fileName.replace(/\.go$/, '') === normalizedName
14
+ return r.name === name || r.packageName === normalizedName
17
15
  })
18
16
 
19
17
  if (!match) {
@@ -22,16 +20,8 @@ export function removeRule(name: string): void {
22
20
  )
23
21
  }
24
22
 
25
- // delete rule file
26
- const rulePath = path.join(lintcnDir, match.fileName)
27
- fs.rmSync(rulePath)
28
- console.log(`Removed ${match.fileName}`)
29
-
30
- // delete test file if exists
31
- const testFileName = match.fileName.replace(/\.go$/, '_test.go')
32
- const testPath = path.join(lintcnDir, testFileName)
33
- if (fs.existsSync(testPath)) {
34
- fs.rmSync(testPath)
35
- console.log(`Removed ${testFileName}`)
36
- }
23
+ // Remove the entire subfolder
24
+ const ruleDir = path.join(lintcnDir, match.packageName)
25
+ fs.rmSync(ruleDir, { recursive: true })
26
+ console.log(`Removed ${match.packageName}/`)
37
27
  }