lintcn 0.6.0 → 0.7.1

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,10 +1,17 @@
1
1
  export interface RuleMetadata {
2
2
  /** kebab-case rule name from // lintcn:name or derived from folder name */
3
3
  name: string;
4
+ /** runtime rule name parsed from Go `rule.Rule{Name: "..."}`. This is
5
+ * the name tsgolint uses in diagnostics and must match --warn flags.
6
+ * Falls back to `name` if the Go Name field can't be parsed. */
7
+ goRuleName: string;
4
8
  /** one-line description from // lintcn:description */
5
9
  description: string;
6
10
  /** original source URL from // lintcn:source */
7
11
  source: string;
12
+ /** severity from // lintcn:severity — 'error' (default) or 'warn'.
13
+ * Warnings are displayed with yellow styling and don't cause exit code 1. */
14
+ severity: 'error' | 'warn';
8
15
  /** exported Go variable name like NoFloatingPromisesRule */
9
16
  varName: string;
10
17
  /** Go package name (= subfolder name, e.g. no_floating_promises) */
@@ -12,5 +19,8 @@ export interface RuleMetadata {
12
19
  }
13
20
  export declare function parseMetadata(content: string): Record<string, string>;
14
21
  export declare function parseRuleVar(content: string): string | undefined;
22
+ /** Extract the Name field from a specific rule.Rule variable's struct literal.
23
+ * Scoped to varName to avoid matching Name fields in unrelated structs. */
24
+ export declare function parseGoRuleName(content: string, varName: string): string | undefined;
15
25
  export declare function discoverRules(lintcnDir: string): RuleMetadata[];
16
26
  //# sourceMappingURL=discover.d.ts.map
@@ -1 +1 @@
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"}
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;;qEAEiE;IACjE,UAAU,EAAE,MAAM,CAAA;IAClB,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd;kFAC8E;IAC9E,QAAQ,EAAE,OAAO,GAAG,MAAM,CAAA;IAC1B,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf,oEAAoE;IACpE,WAAW,EAAE,MAAM,CAAA;CACpB;AAaD,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;4EAC4E;AAC5E,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGpF;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAsD/D"}
package/dist/discover.js CHANGED
@@ -7,6 +7,12 @@ import path from 'node:path';
7
7
  // and optional import alias (e.g. `r.Rule{` if imported as `r "...rule"`)
8
8
  const RULE_VAR_RE = /^\s*var\s+(\w+)\s*=\s*\w*\.?Rule\s*\{/m;
9
9
  const METADATA_RE = /^\/\/\s*lintcn:(\w+)\s+(.+)$/gm;
10
+ // buildGoRuleNameRe creates a regex scoped to a specific rule variable's
11
+ // struct literal, e.g. `var FooRule = rule.Rule{ ... Name: "foo" ... }`.
12
+ // This avoids matching a Name field in an unrelated struct earlier in the file.
13
+ function buildGoRuleNameRe(varName) {
14
+ return new RegExp(`var\\s+${varName}\\s*=\\s*\\w*\\.?Rule\\s*\\{[\\s\\S]*?Name:\\s*"([^"]+)"`);
15
+ }
10
16
  export function parseMetadata(content) {
11
17
  const meta = {};
12
18
  for (const match of content.matchAll(METADATA_RE)) {
@@ -18,6 +24,12 @@ export function parseRuleVar(content) {
18
24
  const match = content.match(RULE_VAR_RE);
19
25
  return match?.[1];
20
26
  }
27
+ /** Extract the Name field from a specific rule.Rule variable's struct literal.
28
+ * Scoped to varName to avoid matching Name fields in unrelated structs. */
29
+ export function parseGoRuleName(content, varName) {
30
+ const match = content.match(buildGoRuleNameRe(varName));
31
+ return match?.[1];
32
+ }
21
33
  export function discoverRules(lintcnDir) {
22
34
  if (!fs.existsSync(lintcnDir)) {
23
35
  return [];
@@ -47,10 +59,15 @@ export function discoverRules(lintcnDir) {
47
59
  if (!varName)
48
60
  continue;
49
61
  const meta = parseMetadata(content);
62
+ const severity = meta.severity === 'warn' ? 'warn' : 'error';
63
+ const displayName = meta.name || entry.name.replace(/_/g, '-');
64
+ const goRuleName = parseGoRuleName(content, varName) || displayName;
50
65
  rules.push({
51
- name: meta.name || entry.name.replace(/_/g, '-'),
52
- description: meta.description || '',
53
- source: meta.source || '',
66
+ name: displayName,
67
+ goRuleName,
68
+ description: meta.description,
69
+ source: meta.source,
70
+ severity,
54
71
  varName,
55
72
  packageName: entry.name,
56
73
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lintcn",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",
@@ -60,6 +60,7 @@
60
60
  "tar": "^7.5.12"
61
61
  },
62
62
  "scripts": {
63
- "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js"
63
+ "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
64
+ "cli": "tsx src/cli"
64
65
  }
65
66
  }
package/src/cache.ts CHANGED
@@ -16,9 +16,9 @@ import { extract } from 'tar'
16
16
  import { execAsync } from './exec.ts'
17
17
 
18
18
  // Pinned tsgolint fork commit — updated with each lintcn release.
19
- // Uses remorses/tsgolint fork which adds internal/runner.Run().
20
- // Only 1 commit on top of upstream — zero modifications to existing files.
21
- export const DEFAULT_TSGOLINT_VERSION = 'e945641eabec22993eda3e7c101692e80417e0ea'
19
+ // Uses remorses/tsgolint fork which adds internal/runner.Run() and
20
+ // TSGOLINT_SNAPSHOT_CWD env var for cwd-relative test snapshots.
21
+ export const DEFAULT_TSGOLINT_VERSION = '518fa0d395effb07a45070643a0cb1a9cf202ce9'
22
22
 
23
23
  // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
24
24
  // Patches from tsgolint/patches/ are applied on top during setup.
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ import { listRules } from './commands/list.ts'
11
11
  import { removeRule } from './commands/remove.ts'
12
12
  import { clean } from './commands/clean.ts'
13
13
  import { DEFAULT_TSGOLINT_VERSION } from './cache.ts'
14
+ import { findLintcnDir } from './paths.ts'
14
15
 
15
16
  const require = createRequire(import.meta.url)
16
17
  const packageJson = require('../package.json') as { version: string }
@@ -18,11 +19,13 @@ const packageJson = require('../package.json') as { version: string }
18
19
  const cli = goke('lintcn')
19
20
 
20
21
  cli
21
- .command('add <url>', 'Add a rule by GitHub URL. Fetches the whole folder into .lintcn/{rule}/')
22
- .example('# Add a rule folder')
22
+ .command('add <url>', 'Add rules by GitHub URL. Supports single rule folders, .lintcn/ directories, or full repo URLs.')
23
+ .example('# Add a single rule folder')
23
24
  .example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
24
25
  .example('# Add by file URL (auto-fetches the whole folder)')
25
26
  .example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
27
+ .example('# Add all rules from a repo (downloads .lintcn/ folder)')
28
+ .example('lintcn add https://github.com/someone/their-project')
26
29
  .action(async (url) => {
27
30
  await addRule(url)
28
31
  })
@@ -43,12 +46,17 @@ cli
43
46
  cli
44
47
  .command('lint', 'Build custom tsgolint binary and run it against the project')
45
48
  .option('--rebuild', 'Force rebuild even if cached binary exists')
49
+ .option('--fix', 'Automatically fix violations')
46
50
  .option('--tsconfig <path>', 'Path to tsconfig.json')
47
51
  .option('--list-files', 'List matched files')
52
+ .option('--all-warnings', 'Show warnings for all files, not just git-changed ones')
48
53
  .option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
49
54
  .action(async (options) => {
50
55
  const tsgolintVersion = (options.tsgolintVersion as string) || DEFAULT_TSGOLINT_VERSION
51
56
  const passthroughArgs: string[] = []
57
+ if (options.fix) {
58
+ passthroughArgs.push('--fix')
59
+ }
52
60
  if (options.tsconfig) {
53
61
  passthroughArgs.push('--tsconfig', options.tsconfig as string)
54
62
  }
@@ -64,6 +72,7 @@ cli
64
72
  rebuild: !!options.rebuild,
65
73
  tsgolintVersion,
66
74
  passthroughArgs,
75
+ allWarnings: !!options.allWarnings,
67
76
  })
68
77
  process.exit(exitCode)
69
78
  })
@@ -73,6 +82,10 @@ cli
73
82
  .option('--rebuild', 'Force rebuild even if cached binary exists')
74
83
  .option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
75
84
  .action(async (options) => {
85
+ if (!findLintcnDir()) {
86
+ console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.')
87
+ return
88
+ }
76
89
  const tsgolintVersion = (options.tsgolintVersion as string) || DEFAULT_TSGOLINT_VERSION
77
90
  const binaryPath = await buildBinary({ rebuild: !!options.rebuild, tsgolintVersion })
78
91
  console.log(binaryPath)
@@ -13,36 +13,57 @@ import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from '../cache.ts'
13
13
  interface ParsedGitHubUrl {
14
14
  owner: string
15
15
  repo: string
16
- ref: string
17
- /** Path to the directory containing the rule files */
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. */
18
19
  dirPath: string
19
20
  /** Set when URL points to a specific file (not a folder) */
20
21
  fileName?: string
21
22
  }
22
23
 
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. */
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. */
26
28
  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
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('/')
31
44
  return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) }
32
45
  }
33
46
 
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 }
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: '' }
55
+ }
56
+
57
+ const subPath = rest.join('/')
58
+
59
+ if (kind === 'tree') {
60
+ if (!ref || !subPath) return null
61
+ return { owner, repo, ref, dirPath: subPath }
39
62
  }
40
63
 
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) }
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) }
46
67
  }
47
68
 
48
69
  return null
@@ -67,6 +88,30 @@ function getGitHubToken(): string | undefined {
67
88
  }
68
89
  }
69
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 })
103
+ if (!response.ok) {
104
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`)
105
+ }
106
+ const data = (await response.json()) as { default_branch: string }
107
+ return data.default_branch
108
+ }
109
+
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
+
70
115
  /** List files in a GitHub directory via the Contents API. */
71
116
  async function listGitHubFolder(owner: string, repo: string, dirPath: string, ref: string): Promise<GitHubContentItem[]> {
72
117
  const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`
@@ -118,71 +163,53 @@ function ensureSourceComment(content: string, sourceUrl: string): string {
118
163
  return lines.join('\n')
119
164
  }
120
165
 
121
- export async function addRule(url: string): Promise<void> {
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
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> {
131
173
  const folderName = path.posix.basename(dirPath)
132
-
133
- console.log(`Fetching ${owner}/${repo}/${dirPath}...`)
134
174
  const items = await listGitHubFolder(owner, repo, dirPath, ref)
135
175
 
136
- // Filter for .go and .json files
137
176
  const filesToFetch = items.filter((item) => {
138
177
  return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'))
139
178
  })
140
179
 
141
180
  if (filesToFetch.length === 0) {
142
- throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`)
143
- }
144
-
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
- )
181
+ console.warn(` Skipping ${folderName}/ no .go files found`)
182
+ return false
154
183
  }
155
184
 
156
- const lintcnDir = getLintcnDir()
157
185
  const ruleDir = path.join(lintcnDir, folderName)
158
186
 
159
- // Clean existing rule folder if it exists
160
187
  if (fs.existsSync(ruleDir)) {
161
188
  fs.rmSync(ruleDir, { recursive: true })
162
- console.log(`Overwriting existing ${folderName}/`)
189
+ console.log(` Overwriting existing ${folderName}/`)
163
190
  }
164
191
 
165
192
  fs.mkdirSync(ruleDir, { recursive: true })
166
193
 
167
- // Fetch and write all files
168
194
  for (const item of filesToFetch) {
169
195
  let content = await fetchFile(item.download_url!)
170
196
 
171
- // Add lintcn:source comment to the main rule file (same name as folder)
172
197
  if (item.name === `${folderName}.go`) {
173
- content = ensureSourceComment(content, url)
198
+ content = ensureSourceComment(content, sourceUrl)
174
199
  }
175
200
 
176
201
  fs.writeFileSync(path.join(ruleDir, item.name), content)
177
- console.log(` ${item.name}`)
202
+ console.log(` ${item.name}`)
178
203
  }
179
204
 
180
- console.log(`Added ${folderName}/ (${filesToFetch.length} files)`)
205
+ console.log(` Added ${folderName}/ (${filesToFetch.length} files)`)
206
+ return true
207
+ }
181
208
 
182
- // Ensure tsgolint source is available
209
+ /** Ensure tsgolint source, refresh symlink, regenerate go.work/go.mod. */
210
+ async function finalizeLintcnDir(lintcnDir: string): Promise<void> {
183
211
  const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION)
184
212
 
185
- // Create/refresh .tsgolint symlink for gopls
186
213
  const tsgolintLink = path.join(lintcnDir, '.tsgolint')
187
214
  try {
188
215
  fs.lstatSync(tsgolintLink)
@@ -195,3 +222,115 @@ export async function addRule(url: string): Promise<void> {
195
222
  generateEditorGoFiles(lintcnDir)
196
223
  console.log('Editor support files generated (go.work, go.mod)')
197
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
+ }
@@ -2,9 +2,10 @@
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
- import { requireLintcnDir } from '../paths.ts'
7
- import { discoverRules } from '../discover.ts'
7
+ import { requireLintcnDir, findLintcnDir } from '../paths.ts'
8
+ import { discoverRules, type RuleMetadata } from '../discover.ts'
8
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'
@@ -99,16 +100,41 @@ export async function lint({
99
100
  rebuild,
100
101
  tsgolintVersion,
101
102
  passthroughArgs,
103
+ allWarnings,
102
104
  }: {
103
105
  rebuild: boolean
104
106
  tsgolintVersion: string
105
107
  passthroughArgs: string[]
108
+ allWarnings: boolean
106
109
  }): Promise<number> {
110
+ if (!findLintcnDir()) {
111
+ console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.')
112
+ return 0
113
+ }
114
+
107
115
  const binaryPath = await buildBinary({ rebuild, tsgolintVersion })
108
116
 
109
- // run the binary with passthrough args, inheriting stdio
117
+ // Discover rules to inject --warn flags for warning-severity rules.
118
+ // buildBinary already discovered rules for compilation, but we need the
119
+ // metadata here to know which rules are warnings at runtime.
120
+ const lintcnDir = requireLintcnDir()
121
+ const rules = discoverRules(lintcnDir)
122
+ const warnArgs = buildWarnArgs(rules)
123
+
124
+ // By default, limit warnings to git-changed files so they don't flood
125
+ // the output in large codebases. --all-warnings bypasses this filter.
126
+ const hasWarnRules = rules.some((r) => r.severity === 'warn')
127
+ let warnFileArgs: string[] = []
128
+ if (hasWarnRules && allWarnings) {
129
+ warnFileArgs = ['--all-warnings']
130
+ } else if (hasWarnRules) {
131
+ warnFileArgs = await buildWarnFileArgs()
132
+ }
133
+
134
+ // run the binary with --warn + --warn-file/--all-warnings flags + passthrough args
135
+ const allArgs = [...warnArgs, ...warnFileArgs, ...passthroughArgs]
110
136
  return new Promise((resolve) => {
111
- const proc = spawn(binaryPath, passthroughArgs, {
137
+ const proc = spawn(binaryPath, allArgs, {
112
138
  stdio: 'inherit',
113
139
  })
114
140
 
@@ -122,3 +148,59 @@ export async function lint({
122
148
  })
123
149
  })
124
150
  }
151
+
152
+ /** Build --warn flags for rules with severity 'warn'.
153
+ * Uses goRuleName (parsed from Go source) to match the runtime name
154
+ * that tsgolint uses in diagnostics, avoiding silent mismatches. */
155
+ function buildWarnArgs(rules: RuleMetadata[]): string[] {
156
+ const args: string[] = []
157
+ for (const rule of rules) {
158
+ if (rule.severity === 'warn') {
159
+ args.push('--warn', rule.goRuleName)
160
+ }
161
+ }
162
+ return args
163
+ }
164
+
165
+ /** Get git-changed files and build --warn-file flags so warnings only
166
+ * appear for new/modified code. Returns [] if git is unavailable or not
167
+ * a git repo — the runner will then show no warnings (safe default).
168
+ * Linting must never crash from this. */
169
+ async function buildWarnFileArgs(): Promise<string[]> {
170
+ try {
171
+ // Get git repo root to resolve relative paths to absolute.
172
+ const topLevelResult = await execAsync('git', ['rev-parse', '--show-toplevel'], { stdio: 'pipe' }).catch(() => null)
173
+ if (!topLevelResult) return []
174
+ const repoRoot = topLevelResult.stdout.trim()
175
+
176
+ // Changed files (staged + unstaged vs HEAD)
177
+ const diffResult = await execAsync('git', ['diff', '--name-only', 'HEAD'], { stdio: 'pipe' }).catch(() => null)
178
+ // Untracked files (new files not yet committed)
179
+ const untrackedResult = await execAsync('git', ['ls-files', '--others', '--exclude-standard'], { stdio: 'pipe' }).catch(() => null)
180
+
181
+ const files = new Set<string>()
182
+
183
+ for (const result of [diffResult, untrackedResult]) {
184
+ if (!result) continue
185
+ for (const line of result.stdout.split('\n')) {
186
+ const trimmed = line.trim()
187
+ if (trimmed) {
188
+ // Resolve to absolute path so it matches SourceFile.FileName() in the runner.
189
+ files.add(path.resolve(repoRoot, trimmed))
190
+ }
191
+ }
192
+ }
193
+
194
+ // No changed files → no --warn-file flags → runner shows no warnings (clean tree)
195
+ if (files.size === 0) return []
196
+
197
+ const args: string[] = []
198
+ for (const file of files) {
199
+ args.push('--warn-file', file)
200
+ }
201
+ return args
202
+ } catch {
203
+ // git not installed, not a repo, or any other failure — no warnings shown
204
+ return []
205
+ }
206
+ }
@@ -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
  }