lintcn 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,59 @@
1
+ ## 0.7.0
2
+
3
+ 1. **`lintcn lint --fix`** — automatically apply fixes in-place. Collects diagnostics per file, applies fixes via the Go runner, and only reports what couldn't be auto-fixed:
4
+
5
+ ```bash
6
+ lintcn lint --fix
7
+ ```
8
+
9
+ 2. **Warning severity system** — rules can now declare `// lintcn:severity warn`. Warnings don't fail CI (exit 0) and are filtered to git-changed files by default so they don't flood large codebases:
10
+
11
+ ```go
12
+ // lintcn:severity warn
13
+ ```
14
+
15
+ Two new flags:
16
+ - `--all-warnings` — show warnings for all files, not just changed ones
17
+ - `lintcn list` now shows a `(warn)` suffix on warning-severity rules
18
+
19
+ 3. **New rule: `no-type-assertion` (warn)** — flags every `as X` / `<X>expr` and includes the actual expression type so agents know what they're working with:
20
+
21
+ ```
22
+ warning: Type assertion to `User ({ name: string; age: number })` from `unknown`
23
+ ```
24
+
25
+ User-defined types show their structural form in parentheses. Standard library types (Array, Map, Promise, etc.) are not expanded. Assertion chains (`x as unknown as Foo`) walk back to the original source type. Casting from `any` is silently allowed (standard untyped-API pattern).
26
+
27
+ 4. **New rule: `no-in-operator` (warn)** — warns on every `in` expression and shows the expanded type of the right-hand operand. When the right side is a union and the property exists in some members but not others, it names which members have it and suggests using a discriminant property instead:
28
+
29
+ ```
30
+ warning: Avoid the `in` operator on `Cat | Dog`. Property `meow` exists in Cat but not Dog.
31
+ Consider using a discriminant property (e.g. `kind`) instead of `in`.
32
+ ```
33
+
34
+ 5. **New rule: `no-redundant-in-check` (error)** — flags `"y" in x` when the type already has `y` as a required non-optional property in all union members. The check can never be false — it's dead code:
35
+
36
+ ```ts
37
+ interface User { name: string; age: number }
38
+ if ('name' in user) { ... } // error: redundant — User always has 'name'
39
+ ```
40
+
41
+ 6. **New built-in rules**: `jsx-no-leaked-render`, `no-unhandled-error`, `no-useless-coalescing` — available via `lintcn add` or by pointing at the `.lintcn/` folder URL.
42
+
43
+ 7. **`lintcn add` with whole repo URL** — download all rules from a repo's `.lintcn/` folder in one shot. Merge semantics: remote rule folders overwrite local ones; local-only rules are preserved:
44
+
45
+ ```bash
46
+ # bare repo URL — fetches all rules from .lintcn/ at repo root
47
+ lintcn add https://github.com/remorses/lintcn
48
+
49
+ # tree URL pointing at a .lintcn collection
50
+ lintcn add https://github.com/remorses/lintcn/tree/main/.lintcn
51
+ ```
52
+
53
+ 8. **Fixed `await-thenable` false positive on overloaded functions** — when a function has multiple call signatures (overloads or intersection-of-callable-types), the rule now checks if any overload returns a thenable before reporting. Fixes false positives like `await extract({...})` from the `tar` package.
54
+
55
+ 9. **Brighter error underline** — error highlights changed from ANSI 256-color 160 (muted red) to 196 (pure bright red). Run `lintcn clean` to clear the old cached binary.
56
+
1
57
  ## 0.6.0
2
58
 
3
59
  1. **Rules now live in subfolders** — each rule is its own Go package under `.lintcn/{rule_name}/`, replacing the old flat `.lintcn/*.go` layout. This eliminates the need to rename `options.go` and `schema.json` companions — they stay in the subfolder with their original names, and the Go package name matches the folder. `lintcn add` now fetches the entire rule folder automatically.
package/README.md CHANGED
@@ -48,8 +48,7 @@ my-project/
48
48
  │ ├── no_floating_promises/
49
49
  │ │ ├── no_floating_promises.go ← rule source (committed)
50
50
  │ │ ├── no_floating_promises_test.go ← tests (committed)
51
- │ │ ├── options.go ← rule options struct
52
- │ │ └── schema.json ← options schema
51
+ │ │ └── options.go ← rule options struct
53
52
  │ ├── await_thenable/
54
53
  │ │ ├── await_thenable.go
55
54
  │ │ └── await_thenable_test.go
@@ -141,17 +140,66 @@ This catches code like:
141
140
 
142
141
  ```typescript
143
142
  // error — result discarded, Error not handled
144
- getUser("id") // returns Error | User
145
- await fetchData("/api") // returns Promise<Error | Data>
143
+ getUser("id"); // returns Error | User
144
+ await fetchData("/api"); // returns Promise<Error | Data>
146
145
 
147
146
  // ok — result is checked
148
- const user = getUser("id")
149
- if (user instanceof Error) return user
147
+ const user = getUser("id");
148
+ if (user instanceof Error) return user;
150
149
 
151
150
  // ok — explicitly discarded
152
- void getUser("id")
151
+ void getUser("id");
153
152
  ```
154
153
 
154
+ ## Warning severity
155
+
156
+ Rules can be configured as **warnings** instead of errors:
157
+
158
+ - **Don't fail CI** — warnings produce exit code 0
159
+ - **Only shown for git-changed files** — warnings for unchanged files are silently skipped
160
+
161
+ This lets you adopt new rules gradually. In a large codebase, enabling a rule as an error means hundreds of violations at once. As a warning, you only see violations in files you're actively changing — fixing issues in new code without blocking the build.
162
+
163
+ ### Configuring a rule as a warning
164
+
165
+ Add `// lintcn:severity warn` to the rule's Go file:
166
+
167
+ ```go
168
+ // lintcn:name no-unhandled-error
169
+ // lintcn:severity warn
170
+ // lintcn:description Disallow discarding Error-typed return values
171
+ ```
172
+
173
+ Rules without `// lintcn:severity` default to `error`.
174
+
175
+ ### When warnings are shown
176
+
177
+ By default, `lintcn lint` runs `git diff` to find changed and untracked files. Warnings are only printed for files in that list:
178
+
179
+ ```bash
180
+ # Warnings only for files in git diff (default)
181
+ npx lintcn lint
182
+
183
+ # Warnings for ALL files, ignoring git diff
184
+ npx lintcn lint --all-warnings
185
+ ```
186
+
187
+ | Scenario | Warnings shown? |
188
+ | ---------------------------------- | ----------------- |
189
+ | File is in `git diff` or untracked | Yes |
190
+ | File is committed and unchanged | No |
191
+ | `--all-warnings` flag is passed | Yes, all files |
192
+ | Git is not installed or not a repo | No warnings shown |
193
+ | Clean git tree (no changes) | No warnings shown |
194
+
195
+ ### Workflow
196
+
197
+ 1. Add a new rule with `lintcn add`
198
+ 2. Set it to `// lintcn:severity warn` in the Go source
199
+ 3. Run `lintcn lint` — only see warnings in files you're currently editing
200
+ 4. Fix warnings as you touch files naturally
201
+ 5. Once the codebase is clean, change to `// lintcn:severity error` (or remove the directive) to enforce it
202
+
155
203
  ## Version pinning
156
204
 
157
205
  **Pin lintcn in your `package.json`** — do not use `^` or `~`:
package/dist/cache.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const DEFAULT_TSGOLINT_VERSION = "e945641eabec22993eda3e7c101692e80417e0ea";
1
+ export declare const DEFAULT_TSGOLINT_VERSION = "23190a08a6315eba8ef11818fc1c38d7b01c9e10";
2
2
  /** Validate version string to prevent path traversal attacks.
3
3
  * Only allows alphanumeric chars, dots, underscores, and hyphens. */
4
4
  export declare function validateVersion(version: string): void;
package/dist/cache.js CHANGED
@@ -16,7 +16,7 @@ import { execAsync } from "./exec.js";
16
16
  // Pinned tsgolint fork commit — updated with each lintcn release.
17
17
  // Uses remorses/tsgolint fork which adds internal/runner.Run().
18
18
  // Only 1 commit on top of upstream — zero modifications to existing files.
19
- export const DEFAULT_TSGOLINT_VERSION = 'e945641eabec22993eda3e7c101692e80417e0ea';
19
+ export const DEFAULT_TSGOLINT_VERSION = '23190a08a6315eba8ef11818fc1c38d7b01c9e10';
20
20
  // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
21
21
  // Patches from tsgolint/patches/ are applied on top during setup.
22
22
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
package/dist/cli.js CHANGED
@@ -13,11 +13,13 @@ const require = createRequire(import.meta.url);
13
13
  const packageJson = require('../package.json');
14
14
  const cli = goke('lintcn');
15
15
  cli
16
- .command('add <url>', 'Add a rule by GitHub URL. Fetches the whole folder into .lintcn/{rule}/')
17
- .example('# Add a rule folder')
16
+ .command('add <url>', 'Add rules by GitHub URL. Supports single rule folders, .lintcn/ directories, or full repo URLs.')
17
+ .example('# Add a single rule folder')
18
18
  .example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
19
19
  .example('# Add by file URL (auto-fetches the whole folder)')
20
20
  .example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
21
+ .example('# Add all rules from a repo (downloads .lintcn/ folder)')
22
+ .example('lintcn add https://github.com/someone/their-project')
21
23
  .action(async (url) => {
22
24
  await addRule(url);
23
25
  });
@@ -35,12 +37,17 @@ cli
35
37
  cli
36
38
  .command('lint', 'Build custom tsgolint binary and run it against the project')
37
39
  .option('--rebuild', 'Force rebuild even if cached binary exists')
40
+ .option('--fix', 'Automatically fix violations')
38
41
  .option('--tsconfig <path>', 'Path to tsconfig.json')
39
42
  .option('--list-files', 'List matched files')
43
+ .option('--all-warnings', 'Show warnings for all files, not just git-changed ones')
40
44
  .option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
41
45
  .action(async (options) => {
42
46
  const tsgolintVersion = options.tsgolintVersion || DEFAULT_TSGOLINT_VERSION;
43
47
  const passthroughArgs = [];
48
+ if (options.fix) {
49
+ passthroughArgs.push('--fix');
50
+ }
44
51
  if (options.tsconfig) {
45
52
  passthroughArgs.push('--tsconfig', options.tsconfig);
46
53
  }
@@ -56,6 +63,7 @@ cli
56
63
  rebuild: !!options.rebuild,
57
64
  tsgolintVersion,
58
65
  passthroughArgs,
66
+ allWarnings: !!options.allWarnings,
59
67
  });
60
68
  process.exit(exitCode);
61
69
  });
@@ -1 +1 @@
1
- {"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AAwHA,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4ExD"}
1
+ {"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AAqQA,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0ExD"}
@@ -8,28 +8,49 @@ import { execSync } from 'node:child_process';
8
8
  import { getLintcnDir } from "../paths.js";
9
9
  import { generateEditorGoFiles } from "../codegen.js";
10
10
  import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from "../cache.js";
11
- /** Parse GitHub blob/tree/raw URLs into components.
12
- * Ref is assumed to be the first path component after blob/tree
13
- * branch names with slashes (e.g. feature/foo) are not supported. */
11
+ /** Parse any GitHub URL into components.
12
+ * Supports: bare repo, /tree/ folders, /blob/ files, raw.githubusercontent.com.
13
+ * Ref is the first path component after blob/tree branch names with slashes
14
+ * (e.g. feature/foo) are not supported. */
14
15
  function parseGitHubUrl(url) {
15
- // GitHub blob URLs: github.com/owner/repo/blob/ref/path/to/file.go
16
- let match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
17
- if (match) {
18
- const [, owner, repo, ref, filePath] = match;
19
- return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) };
16
+ let hostname;
17
+ let segments;
18
+ try {
19
+ const u = new URL(url);
20
+ hostname = u.hostname;
21
+ segments = u.pathname.replace(/\/$/, '').split('/').filter(Boolean);
20
22
  }
21
- // GitHub tree URLs: github.com/owner/repo/tree/ref/path/to/folder
22
- match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/);
23
- if (match) {
24
- const [, owner, repo, ref, dirPath] = match;
25
- return { owner, repo, ref, dirPath };
23
+ catch {
24
+ return null;
26
25
  }
27
- // raw.githubusercontent.com URLs
28
- match = url.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
29
- if (match) {
30
- const [, owner, repo, ref, filePath] = match;
26
+ // raw.githubusercontent.com/owner/repo/ref/path/to/file
27
+ if (hostname === 'raw.githubusercontent.com') {
28
+ if (segments.length < 4)
29
+ return null;
30
+ const [owner, repo, ref, ...rest] = segments;
31
+ const filePath = rest.join('/');
31
32
  return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) };
32
33
  }
34
+ if (hostname !== 'github.com')
35
+ return null;
36
+ if (segments.length < 2)
37
+ return null;
38
+ const [owner, repo, kind, ref, ...rest] = segments;
39
+ // Bare repo URL: github.com/owner/repo
40
+ if (!kind) {
41
+ return { owner, repo, ref: undefined, dirPath: '' };
42
+ }
43
+ const subPath = rest.join('/');
44
+ if (kind === 'tree') {
45
+ if (!ref || !subPath)
46
+ return null;
47
+ return { owner, repo, ref, dirPath: subPath };
48
+ }
49
+ if (kind === 'blob') {
50
+ if (!ref || !subPath)
51
+ return null;
52
+ return { owner, repo, ref, dirPath: path.posix.dirname(subPath), fileName: path.posix.basename(subPath) };
53
+ }
33
54
  return null;
34
55
  }
35
56
  /** Get a GitHub auth token from gh CLI, GITHUB_TOKEN env, or return undefined. */
@@ -45,6 +66,28 @@ function getGitHubToken() {
45
66
  return undefined;
46
67
  }
47
68
  }
69
+ /** Resolve the default branch for a repo (e.g. "main", "master"). */
70
+ async function resolveDefaultBranch(owner, repo) {
71
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
72
+ const headers = {
73
+ 'Accept': 'application/vnd.github.v3+json',
74
+ 'User-Agent': 'lintcn',
75
+ };
76
+ const token = getGitHubToken();
77
+ if (token) {
78
+ headers['Authorization'] = `Bearer ${token}`;
79
+ }
80
+ const response = await fetch(apiUrl, { headers });
81
+ if (!response.ok) {
82
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`);
83
+ }
84
+ const data = (await response.json());
85
+ return data.default_branch;
86
+ }
87
+ /** Files/dirs in .lintcn/ that are generated and should not be treated as rule folders. */
88
+ const LINTCN_GENERATED = new Set([
89
+ '.tsgolint', '.gitignore', 'go.work', 'go.work.sum', 'go.mod', 'go.sum',
90
+ ]);
48
91
  /** List files in a GitHub directory via the Contents API. */
49
92
  async function listGitHubFolder(owner, repo, dirPath, ref) {
50
93
  const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`;
@@ -91,53 +134,39 @@ function ensureSourceComment(content, sourceUrl) {
91
134
  lines.splice(insertIndex, 0, `// lintcn:source ${sourceUrl}`);
92
135
  return lines.join('\n');
93
136
  }
94
- export async function addRule(url) {
95
- const parsed = parseGitHubUrl(url);
96
- if (!parsed) {
97
- throw new Error('Only GitHub URLs are supported. Pass a /blob/ (file) or /tree/ (folder) URL.\n' +
98
- 'Example: lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises');
99
- }
100
- const { owner, repo, ref, dirPath } = parsed;
137
+ /** Download a single rule folder into .lintcn/{folderName}/.
138
+ * Overwrites existing folder if present.
139
+ * Returns true if the rule was added, false if skipped (no .go files). */
140
+ async function downloadSingleRule(owner, repo, ref, dirPath, lintcnDir, sourceUrl) {
101
141
  const folderName = path.posix.basename(dirPath);
102
- console.log(`Fetching ${owner}/${repo}/${dirPath}...`);
103
142
  const items = await listGitHubFolder(owner, repo, dirPath, ref);
104
- // Filter for .go and .json files
105
143
  const filesToFetch = items.filter((item) => {
106
144
  return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'));
107
145
  });
108
146
  if (filesToFetch.length === 0) {
109
- throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`);
110
- }
111
- // Warn if this doesn't look like a single-rule folder (too many main .go files)
112
- const mainGoFiles = filesToFetch.filter((f) => {
113
- return f.name.endsWith('.go') && !f.name.endsWith('_test.go') && f.name !== 'options.go';
114
- });
115
- if (mainGoFiles.length > 3) {
116
- console.warn(`Warning: folder has ${mainGoFiles.length} non-test .go files. ` +
117
- `This may be a directory of multiple rules — consider using a more specific URL.`);
147
+ console.warn(` Skipping ${folderName}/ no .go files found`);
148
+ return false;
118
149
  }
119
- const lintcnDir = getLintcnDir();
120
150
  const ruleDir = path.join(lintcnDir, folderName);
121
- // Clean existing rule folder if it exists
122
151
  if (fs.existsSync(ruleDir)) {
123
152
  fs.rmSync(ruleDir, { recursive: true });
124
- console.log(`Overwriting existing ${folderName}/`);
153
+ console.log(` Overwriting existing ${folderName}/`);
125
154
  }
126
155
  fs.mkdirSync(ruleDir, { recursive: true });
127
- // Fetch and write all files
128
156
  for (const item of filesToFetch) {
129
157
  let content = await fetchFile(item.download_url);
130
- // Add lintcn:source comment to the main rule file (same name as folder)
131
158
  if (item.name === `${folderName}.go`) {
132
- content = ensureSourceComment(content, url);
159
+ content = ensureSourceComment(content, sourceUrl);
133
160
  }
134
161
  fs.writeFileSync(path.join(ruleDir, item.name), content);
135
- console.log(` ${item.name}`);
162
+ console.log(` ${item.name}`);
136
163
  }
137
- console.log(`Added ${folderName}/ (${filesToFetch.length} files)`);
138
- // Ensure tsgolint source is available
164
+ console.log(` Added ${folderName}/ (${filesToFetch.length} files)`);
165
+ return true;
166
+ }
167
+ /** Ensure tsgolint source, refresh symlink, regenerate go.work/go.mod. */
168
+ async function finalizeLintcnDir(lintcnDir) {
139
169
  const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION);
140
- // Create/refresh .tsgolint symlink for gopls
141
170
  const tsgolintLink = path.join(lintcnDir, '.tsgolint');
142
171
  try {
143
172
  fs.lstatSync(tsgolintLink);
@@ -150,3 +179,89 @@ export async function addRule(url) {
150
179
  generateEditorGoFiles(lintcnDir);
151
180
  console.log('Editor support files generated (go.work, go.mod)');
152
181
  }
182
+ /** Download all rule subfolders from a remote .lintcn/ directory.
183
+ * Each subfolder is treated as a separate rule. Local rules not present
184
+ * in the remote are preserved (merge, not replace). */
185
+ async function addLintcnFolder(owner, repo, ref, lintcnPath, sourceUrl) {
186
+ console.log(`Fetching .lintcn/ from ${owner}/${repo}...`);
187
+ const items = await listGitHubFolder(owner, repo, lintcnPath, ref);
188
+ const ruleDirs = items.filter((item) => {
189
+ return item.type === 'dir' && !LINTCN_GENERATED.has(item.name) && !item.name.startsWith('.');
190
+ });
191
+ if (ruleDirs.length === 0) {
192
+ throw new Error(`No rule folders found in ${lintcnPath}. Is this a .lintcn/ directory?`);
193
+ }
194
+ console.log(`Found ${ruleDirs.length} rule(s)`);
195
+ const lintcnDir = getLintcnDir();
196
+ let added = 0;
197
+ for (const dir of ruleDirs) {
198
+ const ruleDirPath = lintcnPath ? `${lintcnPath}/${dir.name}` : dir.name;
199
+ const ok = await downloadSingleRule(owner, repo, ref, ruleDirPath, lintcnDir, sourceUrl);
200
+ if (ok)
201
+ added++;
202
+ }
203
+ if (added === 0) {
204
+ throw new Error(`No rule folders with .go files found in ${lintcnPath}.`);
205
+ }
206
+ await finalizeLintcnDir(lintcnDir);
207
+ console.log(`\nDone — added ${added} rule(s) from ${owner}/${repo}`);
208
+ }
209
+ export async function addRule(url) {
210
+ const parsed = parseGitHubUrl(url);
211
+ if (!parsed) {
212
+ throw new Error('Only GitHub URLs are supported.\n' +
213
+ 'Examples:\n' +
214
+ ' lintcn add https://github.com/someone/their-project\n' +
215
+ ' lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises');
216
+ }
217
+ const { owner, repo, fileName } = parsed;
218
+ let { ref, dirPath } = parsed;
219
+ // Bare repo URL — resolve default branch and look for .lintcn/ at root
220
+ if (ref === undefined) {
221
+ ref = await resolveDefaultBranch(owner, repo);
222
+ console.log(`Resolved default branch: ${ref}`);
223
+ const rootItems = await listGitHubFolder(owner, repo, '', ref);
224
+ const lintcnEntry = rootItems.find((item) => item.type === 'dir' && item.name === '.lintcn');
225
+ if (!lintcnEntry) {
226
+ throw new Error(`No .lintcn/ directory found in ${owner}/${repo}.\n` +
227
+ 'The repo needs a .lintcn/ folder with rule subfolders.');
228
+ }
229
+ await addLintcnFolder(owner, repo, ref, '.lintcn', url);
230
+ return;
231
+ }
232
+ // For blob/raw URLs, dirPath already points at the parent folder
233
+ // For tree URLs, dirPath is the folder itself — but it might be a .lintcn/ collection
234
+ if (!fileName) {
235
+ const items = await listGitHubFolder(owner, repo, dirPath, ref);
236
+ const hasGoFiles = items.some((i) => i.type === 'file' && i.name.endsWith('.go'));
237
+ const hasCandidateSubdirs = items.some((i) => {
238
+ return i.type === 'dir' && !LINTCN_GENERATED.has(i.name) && !i.name.startsWith('.');
239
+ });
240
+ const isLintcnDir = path.posix.basename(dirPath) === '.lintcn';
241
+ // Collection if: explicitly .lintcn/, or has subdirs but no .go files at root.
242
+ // A single rule folder with testdata/ subdirs won't be misdetected because
243
+ // it also has .go files at root.
244
+ if (isLintcnDir || (!hasGoFiles && hasCandidateSubdirs)) {
245
+ await addLintcnFolder(owner, repo, ref, dirPath, url);
246
+ return;
247
+ }
248
+ }
249
+ // Single rule — download one folder
250
+ const folderName = path.posix.basename(dirPath);
251
+ console.log(`Fetching ${owner}/${repo}/${dirPath}...`);
252
+ const lintcnDir = getLintcnDir();
253
+ // Warn if this doesn't look like a single-rule folder (too many main .go files)
254
+ const items = await listGitHubFolder(owner, repo, dirPath, ref);
255
+ const mainGoFiles = items.filter((f) => {
256
+ return f.type === 'file' && f.name.endsWith('.go') && !f.name.endsWith('_test.go') && f.name !== 'options.go';
257
+ });
258
+ if (mainGoFiles.length > 3) {
259
+ console.warn(`Warning: folder has ${mainGoFiles.length} non-test .go files. ` +
260
+ `This may be a directory of multiple rules — consider using a more specific URL.`);
261
+ }
262
+ const ok = await downloadSingleRule(owner, repo, ref, dirPath, lintcnDir, url);
263
+ if (!ok) {
264
+ throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`);
265
+ }
266
+ await finalizeLintcnDir(lintcnDir);
267
+ }
@@ -2,9 +2,10 @@ export declare function buildBinary({ rebuild, tsgolintVersion, }: {
2
2
  rebuild: boolean;
3
3
  tsgolintVersion: string;
4
4
  }): Promise<string>;
5
- export declare function lint({ rebuild, tsgolintVersion, passthroughArgs, }: {
5
+ export declare function lint({ rebuild, tsgolintVersion, passthroughArgs, allWarnings, }: {
6
6
  rebuild: boolean;
7
7
  tsgolintVersion: string;
8
8
  passthroughArgs: string[];
9
+ allWarnings: boolean;
9
10
  }): Promise<number>;
10
11
  //# sourceMappingURL=lint.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/commands/lint.ts"],"names":[],"mappings":"AAuBA,wBAAsB,WAAW,CAAC,EAChC,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkElB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;CAC1B,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBlB"}
1
+ {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/commands/lint.ts"],"names":[],"mappings":"AAwBA,wBAAsB,WAAW,CAAC,EAChC,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkElB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,EACf,WAAW,GACZ,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoClB"}
@@ -1,6 +1,7 @@
1
1
  // lintcn lint — build a custom tsgolint binary and run it against the project.
2
2
  // Handles Go workspace generation, compilation with caching, and execution.
3
3
  import fs from 'node:fs';
4
+ import path from 'node:path';
4
5
  import { spawn } from 'node:child_process';
5
6
  import { requireLintcnDir } from "../paths.js";
6
7
  import { discoverRules } from "../discover.js";
@@ -73,11 +74,28 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
73
74
  console.log('Build complete');
74
75
  return binaryPath;
75
76
  }
76
- export async function lint({ rebuild, tsgolintVersion, passthroughArgs, }) {
77
+ export async function lint({ rebuild, tsgolintVersion, passthroughArgs, allWarnings, }) {
77
78
  const binaryPath = await buildBinary({ rebuild, tsgolintVersion });
78
- // run the binary with passthrough args, inheriting stdio
79
+ // Discover rules to inject --warn flags for warning-severity rules.
80
+ // buildBinary already discovered rules for compilation, but we need the
81
+ // metadata here to know which rules are warnings at runtime.
82
+ const lintcnDir = requireLintcnDir();
83
+ const rules = discoverRules(lintcnDir);
84
+ const warnArgs = buildWarnArgs(rules);
85
+ // By default, limit warnings to git-changed files so they don't flood
86
+ // the output in large codebases. --all-warnings bypasses this filter.
87
+ const hasWarnRules = rules.some((r) => r.severity === 'warn');
88
+ let warnFileArgs = [];
89
+ if (hasWarnRules && allWarnings) {
90
+ warnFileArgs = ['--all-warnings'];
91
+ }
92
+ else if (hasWarnRules) {
93
+ warnFileArgs = await buildWarnFileArgs();
94
+ }
95
+ // run the binary with --warn + --warn-file/--all-warnings flags + passthrough args
96
+ const allArgs = [...warnArgs, ...warnFileArgs, ...passthroughArgs];
79
97
  return new Promise((resolve) => {
80
- const proc = spawn(binaryPath, passthroughArgs, {
98
+ const proc = spawn(binaryPath, allArgs, {
81
99
  stdio: 'inherit',
82
100
  });
83
101
  proc.on('error', (err) => {
@@ -89,3 +107,56 @@ export async function lint({ rebuild, tsgolintVersion, passthroughArgs, }) {
89
107
  });
90
108
  });
91
109
  }
110
+ /** Build --warn flags for rules with severity 'warn'.
111
+ * Uses goRuleName (parsed from Go source) to match the runtime name
112
+ * that tsgolint uses in diagnostics, avoiding silent mismatches. */
113
+ function buildWarnArgs(rules) {
114
+ const args = [];
115
+ for (const rule of rules) {
116
+ if (rule.severity === 'warn') {
117
+ args.push('--warn', rule.goRuleName);
118
+ }
119
+ }
120
+ return args;
121
+ }
122
+ /** Get git-changed files and build --warn-file flags so warnings only
123
+ * appear for new/modified code. Returns [] if git is unavailable or not
124
+ * a git repo — the runner will then show no warnings (safe default).
125
+ * Linting must never crash from this. */
126
+ async function buildWarnFileArgs() {
127
+ try {
128
+ // Get git repo root to resolve relative paths to absolute.
129
+ const topLevelResult = await execAsync('git', ['rev-parse', '--show-toplevel'], { stdio: 'pipe' }).catch(() => null);
130
+ if (!topLevelResult)
131
+ return [];
132
+ const repoRoot = topLevelResult.stdout.trim();
133
+ // Changed files (staged + unstaged vs HEAD)
134
+ const diffResult = await execAsync('git', ['diff', '--name-only', 'HEAD'], { stdio: 'pipe' }).catch(() => null);
135
+ // Untracked files (new files not yet committed)
136
+ const untrackedResult = await execAsync('git', ['ls-files', '--others', '--exclude-standard'], { stdio: 'pipe' }).catch(() => null);
137
+ const files = new Set();
138
+ for (const result of [diffResult, untrackedResult]) {
139
+ if (!result)
140
+ continue;
141
+ for (const line of result.stdout.split('\n')) {
142
+ const trimmed = line.trim();
143
+ if (trimmed) {
144
+ // Resolve to absolute path so it matches SourceFile.FileName() in the runner.
145
+ files.add(path.resolve(repoRoot, trimmed));
146
+ }
147
+ }
148
+ }
149
+ // No changed files → no --warn-file flags → runner shows no warnings (clean tree)
150
+ if (files.size === 0)
151
+ return [];
152
+ const args = [];
153
+ for (const file of files) {
154
+ args.push('--warn-file', file);
155
+ }
156
+ return args;
157
+ }
158
+ catch {
159
+ // git not installed, not a repo, or any other failure — no warnings shown
160
+ return [];
161
+ }
162
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,IAAI,IAAI,CA0BhC"}
1
+ {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,IAAI,IAAI,CA6BhC"}
@@ -13,9 +13,12 @@ export function listRules() {
13
13
  return;
14
14
  }
15
15
  console.log('Installed rules:\n');
16
- const maxNameLen = Math.max(...rules.map((r) => { return r.name.length; }));
16
+ const maxNameLen = Math.max(...rules.map((r) => {
17
+ return r.name.length + (r.severity === 'warn' ? 7 : 0); // ' (warn)' = 7 chars
18
+ }));
17
19
  for (const rule of rules) {
18
- const name = rule.name.padEnd(maxNameLen + 2);
20
+ const suffix = rule.severity === 'warn' ? ' (warn)' : '';
21
+ const name = (rule.name + suffix).padEnd(maxNameLen + 2);
19
22
  const desc = rule.description || '(no description)';
20
23
  console.log(` ${name}${desc}`);
21
24
  }
@@ -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.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",
@@ -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
@@ -18,7 +18,7 @@ import { execAsync } from './exec.ts'
18
18
  // Pinned tsgolint fork commit — updated with each lintcn release.
19
19
  // Uses remorses/tsgolint fork which adds internal/runner.Run().
20
20
  // Only 1 commit on top of upstream — zero modifications to existing files.
21
- export const DEFAULT_TSGOLINT_VERSION = 'e945641eabec22993eda3e7c101692e80417e0ea'
21
+ export const DEFAULT_TSGOLINT_VERSION = '23190a08a6315eba8ef11818fc1c38d7b01c9e10'
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
@@ -18,11 +18,13 @@ const packageJson = require('../package.json') as { version: string }
18
18
  const cli = goke('lintcn')
19
19
 
20
20
  cli
21
- .command('add <url>', 'Add a rule by GitHub URL. Fetches the whole folder into .lintcn/{rule}/')
22
- .example('# Add a rule folder')
21
+ .command('add <url>', 'Add rules by GitHub URL. Supports single rule folders, .lintcn/ directories, or full repo URLs.')
22
+ .example('# Add a single rule folder')
23
23
  .example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
24
24
  .example('# Add by file URL (auto-fetches the whole folder)')
25
25
  .example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
26
+ .example('# Add all rules from a repo (downloads .lintcn/ folder)')
27
+ .example('lintcn add https://github.com/someone/their-project')
26
28
  .action(async (url) => {
27
29
  await addRule(url)
28
30
  })
@@ -43,12 +45,17 @@ cli
43
45
  cli
44
46
  .command('lint', 'Build custom tsgolint binary and run it against the project')
45
47
  .option('--rebuild', 'Force rebuild even if cached binary exists')
48
+ .option('--fix', 'Automatically fix violations')
46
49
  .option('--tsconfig <path>', 'Path to tsconfig.json')
47
50
  .option('--list-files', 'List matched files')
51
+ .option('--all-warnings', 'Show warnings for all files, not just git-changed ones')
48
52
  .option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
49
53
  .action(async (options) => {
50
54
  const tsgolintVersion = (options.tsgolintVersion as string) || DEFAULT_TSGOLINT_VERSION
51
55
  const passthroughArgs: string[] = []
56
+ if (options.fix) {
57
+ passthroughArgs.push('--fix')
58
+ }
52
59
  if (options.tsconfig) {
53
60
  passthroughArgs.push('--tsconfig', options.tsconfig as string)
54
61
  }
@@ -64,6 +71,7 @@ cli
64
71
  rebuild: !!options.rebuild,
65
72
  tsgolintVersion,
66
73
  passthroughArgs,
74
+ allWarnings: !!options.allWarnings,
67
75
  })
68
76
  process.exit(exitCode)
69
77
  })
@@ -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
7
  import { requireLintcnDir } from '../paths.ts'
7
- import { discoverRules } from '../discover.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,36 @@ 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> {
107
110
  const binaryPath = await buildBinary({ rebuild, tsgolintVersion })
108
111
 
109
- // 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]
110
131
  return new Promise((resolve) => {
111
- const proc = spawn(binaryPath, passthroughArgs, {
132
+ const proc = spawn(binaryPath, allArgs, {
112
133
  stdio: 'inherit',
113
134
  })
114
135
 
@@ -122,3 +143,59 @@ export async function lint({
122
143
  })
123
144
  })
124
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
  }
package/src/discover.ts CHANGED
@@ -8,10 +8,17 @@ import path from 'node:path'
8
8
  export interface RuleMetadata {
9
9
  /** kebab-case rule name from // lintcn:name or derived from folder name */
10
10
  name: string
11
+ /** runtime rule name parsed from Go `rule.Rule{Name: "..."}`. This is
12
+ * the name tsgolint uses in diagnostics and must match --warn flags.
13
+ * Falls back to `name` if the Go Name field can't be parsed. */
14
+ goRuleName: string
11
15
  /** one-line description from // lintcn:description */
12
16
  description: string
13
17
  /** original source URL from // lintcn:source */
14
18
  source: string
19
+ /** severity from // lintcn:severity — 'error' (default) or 'warn'.
20
+ * Warnings are displayed with yellow styling and don't cause exit code 1. */
21
+ severity: 'error' | 'warn'
15
22
  /** exported Go variable name like NoFloatingPromisesRule */
16
23
  varName: string
17
24
  /** Go package name (= subfolder name, e.g. no_floating_promises) */
@@ -22,6 +29,12 @@ export interface RuleMetadata {
22
29
  // and optional import alias (e.g. `r.Rule{` if imported as `r "...rule"`)
23
30
  const RULE_VAR_RE = /^\s*var\s+(\w+)\s*=\s*\w*\.?Rule\s*\{/m
24
31
  const METADATA_RE = /^\/\/\s*lintcn:(\w+)\s+(.+)$/gm
32
+ // buildGoRuleNameRe creates a regex scoped to a specific rule variable's
33
+ // struct literal, e.g. `var FooRule = rule.Rule{ ... Name: "foo" ... }`.
34
+ // This avoids matching a Name field in an unrelated struct earlier in the file.
35
+ function buildGoRuleNameRe(varName: string): RegExp {
36
+ return new RegExp(`var\\s+${varName}\\s*=\\s*\\w*\\.?Rule\\s*\\{[\\s\\S]*?Name:\\s*"([^"]+)"`)
37
+ }
25
38
 
26
39
  export function parseMetadata(content: string): Record<string, string> {
27
40
  const meta: Record<string, string> = {}
@@ -36,6 +49,13 @@ export function parseRuleVar(content: string): string | undefined {
36
49
  return match?.[1]
37
50
  }
38
51
 
52
+ /** Extract the Name field from a specific rule.Rule variable's struct literal.
53
+ * Scoped to varName to avoid matching Name fields in unrelated structs. */
54
+ export function parseGoRuleName(content: string, varName: string): string | undefined {
55
+ const match = content.match(buildGoRuleNameRe(varName))
56
+ return match?.[1]
57
+ }
58
+
39
59
  export function discoverRules(lintcnDir: string): RuleMetadata[] {
40
60
  if (!fs.existsSync(lintcnDir)) {
41
61
  return []
@@ -73,10 +93,16 @@ export function discoverRules(lintcnDir: string): RuleMetadata[] {
73
93
 
74
94
  const meta = parseMetadata(content)
75
95
 
96
+ const severity = meta.severity === 'warn' ? 'warn' as const : 'error' as const
97
+ const displayName = meta.name || entry.name.replace(/_/g, '-')
98
+ const goRuleName = parseGoRuleName(content, varName) || displayName
99
+
76
100
  rules.push({
77
- name: meta.name || entry.name.replace(/_/g, '-'),
78
- description: meta.description || '',
79
- source: meta.source || '',
101
+ name: displayName,
102
+ goRuleName,
103
+ description: meta.description,
104
+ source: meta.source,
105
+ severity,
80
106
  varName,
81
107
  packageName: entry.name,
82
108
  })