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.
- package/CHANGELOG.md +62 -0
- package/README.md +55 -7
- package/dist/cache.d.ts +1 -1
- package/dist/cache.js +3 -3
- package/dist/cli.js +15 -2
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +160 -45
- package/dist/commands/lint.d.ts +2 -1
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +79 -4
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +5 -2
- package/dist/discover.d.ts +10 -0
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +20 -3
- package/package.json +3 -2
- package/src/cache.ts +3 -3
- package/src/cli.ts +15 -2
- package/src/commands/add.ts +193 -54
- package/src/commands/lint.ts +86 -4
- package/src/commands/list.ts +5 -2
- package/src/discover.ts +29 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
+
## 0.7.1
|
|
2
|
+
|
|
3
|
+
1. **`lintcn lint` / `lintcn build` exit 0 when `.lintcn/` not found** — instead of throwing and exiting 1, they now print a helpful message and exit cleanly. Useful when running lintcn in CI on repos that haven't set up rules yet.
|
|
4
|
+
|
|
5
|
+
2. **Snapshot files land in `.lintcn/<rule>/__snapshots__/`** — bumped tsgolint to `518fa0d`. Rule tests now write snapshots relative to the test package directory instead of the cached tsgolint source, so they're committed alongside your rule and survive `lintcn clean`. Set `TSGOLINT_SNAPSHOT_CWD=true` when running `go test` to get this behavior.
|
|
6
|
+
|
|
7
|
+
## 0.7.0
|
|
8
|
+
|
|
9
|
+
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:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
lintcn lint --fix
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
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:
|
|
16
|
+
|
|
17
|
+
```go
|
|
18
|
+
// lintcn:severity warn
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Two new flags:
|
|
22
|
+
- `--all-warnings` — show warnings for all files, not just changed ones
|
|
23
|
+
- `lintcn list` now shows a `(warn)` suffix on warning-severity rules
|
|
24
|
+
|
|
25
|
+
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:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
warning: Type assertion to `User ({ name: string; age: number })` from `unknown`
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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).
|
|
32
|
+
|
|
33
|
+
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:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
warning: Avoid the `in` operator on `Cat | Dog`. Property `meow` exists in Cat but not Dog.
|
|
37
|
+
Consider using a discriminant property (e.g. `kind`) instead of `in`.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
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:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
interface User { name: string; age: number }
|
|
44
|
+
if ('name' in user) { ... } // error: redundant — User always has 'name'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
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:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# bare repo URL — fetches all rules from .lintcn/ at repo root
|
|
53
|
+
lintcn add https://github.com/remorses/lintcn
|
|
54
|
+
|
|
55
|
+
# tree URL pointing at a .lintcn collection
|
|
56
|
+
lintcn add https://github.com/remorses/lintcn/tree/main/.lintcn
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
1
63
|
## 0.6.0
|
|
2
64
|
|
|
3
65
|
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
|
-
│ │
|
|
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")
|
|
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 = "
|
|
1
|
+
export declare const DEFAULT_TSGOLINT_VERSION = "518fa0d395effb07a45070643a0cb1a9cf202ce9";
|
|
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
|
@@ -14,9 +14,9 @@ import { pipeline } from 'node:stream/promises';
|
|
|
14
14
|
import { extract } from 'tar';
|
|
15
15
|
import { execAsync } from "./exec.js";
|
|
16
16
|
// Pinned tsgolint fork commit — updated with each lintcn release.
|
|
17
|
-
// Uses remorses/tsgolint fork which adds internal/runner.Run()
|
|
18
|
-
//
|
|
19
|
-
export const DEFAULT_TSGOLINT_VERSION = '
|
|
17
|
+
// Uses remorses/tsgolint fork which adds internal/runner.Run() and
|
|
18
|
+
// TSGOLINT_SNAPSHOT_CWD env var for cwd-relative test snapshots.
|
|
19
|
+
export const DEFAULT_TSGOLINT_VERSION = '518fa0d395effb07a45070643a0cb1a9cf202ce9';
|
|
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
|
@@ -9,15 +9,18 @@ import { listRules } from "./commands/list.js";
|
|
|
9
9
|
import { removeRule } from "./commands/remove.js";
|
|
10
10
|
import { clean } from "./commands/clean.js";
|
|
11
11
|
import { DEFAULT_TSGOLINT_VERSION } from "./cache.js";
|
|
12
|
+
import { findLintcnDir } from "./paths.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const packageJson = require('../package.json');
|
|
14
15
|
const cli = goke('lintcn');
|
|
15
16
|
cli
|
|
16
|
-
.command('add <url>', 'Add
|
|
17
|
-
.example('# Add a rule folder')
|
|
17
|
+
.command('add <url>', 'Add rules by GitHub URL. Supports single rule folders, .lintcn/ directories, or full repo URLs.')
|
|
18
|
+
.example('# Add a single rule folder')
|
|
18
19
|
.example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
|
|
19
20
|
.example('# Add by file URL (auto-fetches the whole folder)')
|
|
20
21
|
.example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
|
|
22
|
+
.example('# Add all rules from a repo (downloads .lintcn/ folder)')
|
|
23
|
+
.example('lintcn add https://github.com/someone/their-project')
|
|
21
24
|
.action(async (url) => {
|
|
22
25
|
await addRule(url);
|
|
23
26
|
});
|
|
@@ -35,12 +38,17 @@ cli
|
|
|
35
38
|
cli
|
|
36
39
|
.command('lint', 'Build custom tsgolint binary and run it against the project')
|
|
37
40
|
.option('--rebuild', 'Force rebuild even if cached binary exists')
|
|
41
|
+
.option('--fix', 'Automatically fix violations')
|
|
38
42
|
.option('--tsconfig <path>', 'Path to tsconfig.json')
|
|
39
43
|
.option('--list-files', 'List matched files')
|
|
44
|
+
.option('--all-warnings', 'Show warnings for all files, not just git-changed ones')
|
|
40
45
|
.option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
|
|
41
46
|
.action(async (options) => {
|
|
42
47
|
const tsgolintVersion = options.tsgolintVersion || DEFAULT_TSGOLINT_VERSION;
|
|
43
48
|
const passthroughArgs = [];
|
|
49
|
+
if (options.fix) {
|
|
50
|
+
passthroughArgs.push('--fix');
|
|
51
|
+
}
|
|
44
52
|
if (options.tsconfig) {
|
|
45
53
|
passthroughArgs.push('--tsconfig', options.tsconfig);
|
|
46
54
|
}
|
|
@@ -56,6 +64,7 @@ cli
|
|
|
56
64
|
rebuild: !!options.rebuild,
|
|
57
65
|
tsgolintVersion,
|
|
58
66
|
passthroughArgs,
|
|
67
|
+
allWarnings: !!options.allWarnings,
|
|
59
68
|
});
|
|
60
69
|
process.exit(exitCode);
|
|
61
70
|
});
|
|
@@ -64,6 +73,10 @@ cli
|
|
|
64
73
|
.option('--rebuild', 'Force rebuild even if cached binary exists')
|
|
65
74
|
.option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
|
|
66
75
|
.action(async (options) => {
|
|
76
|
+
if (!findLintcnDir()) {
|
|
77
|
+
console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
67
80
|
const tsgolintVersion = options.tsgolintVersion || DEFAULT_TSGOLINT_VERSION;
|
|
68
81
|
const binaryPath = await buildBinary({ rebuild: !!options.rebuild, tsgolintVersion });
|
|
69
82
|
console.log(binaryPath);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/commands/add.js
CHANGED
|
@@ -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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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,
|
|
159
|
+
content = ensureSourceComment(content, sourceUrl);
|
|
133
160
|
}
|
|
134
161
|
fs.writeFileSync(path.join(ruleDir, item.name), content);
|
|
135
|
-
console.log(`
|
|
162
|
+
console.log(` ${item.name}`);
|
|
136
163
|
}
|
|
137
|
-
console.log(`Added ${folderName}/ (${filesToFetch.length} files)`);
|
|
138
|
-
|
|
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
|
+
}
|
package/dist/commands/lint.d.ts
CHANGED
|
@@ -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":"
|
|
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,CAyClB"}
|
package/dist/commands/lint.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
|
-
import { requireLintcnDir } from "../paths.js";
|
|
6
|
+
import { requireLintcnDir, findLintcnDir } from "../paths.js";
|
|
6
7
|
import { discoverRules } from "../discover.js";
|
|
7
8
|
import { generateBuildWorkspace, generateEditorGoFiles } from "../codegen.js";
|
|
8
9
|
import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
|
|
@@ -73,11 +74,32 @@ 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, }) {
|
|
78
|
+
if (!findLintcnDir()) {
|
|
79
|
+
console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.');
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
77
82
|
const binaryPath = await buildBinary({ rebuild, tsgolintVersion });
|
|
78
|
-
//
|
|
83
|
+
// Discover rules to inject --warn flags for warning-severity rules.
|
|
84
|
+
// buildBinary already discovered rules for compilation, but we need the
|
|
85
|
+
// metadata here to know which rules are warnings at runtime.
|
|
86
|
+
const lintcnDir = requireLintcnDir();
|
|
87
|
+
const rules = discoverRules(lintcnDir);
|
|
88
|
+
const warnArgs = buildWarnArgs(rules);
|
|
89
|
+
// By default, limit warnings to git-changed files so they don't flood
|
|
90
|
+
// the output in large codebases. --all-warnings bypasses this filter.
|
|
91
|
+
const hasWarnRules = rules.some((r) => r.severity === 'warn');
|
|
92
|
+
let warnFileArgs = [];
|
|
93
|
+
if (hasWarnRules && allWarnings) {
|
|
94
|
+
warnFileArgs = ['--all-warnings'];
|
|
95
|
+
}
|
|
96
|
+
else if (hasWarnRules) {
|
|
97
|
+
warnFileArgs = await buildWarnFileArgs();
|
|
98
|
+
}
|
|
99
|
+
// run the binary with --warn + --warn-file/--all-warnings flags + passthrough args
|
|
100
|
+
const allArgs = [...warnArgs, ...warnFileArgs, ...passthroughArgs];
|
|
79
101
|
return new Promise((resolve) => {
|
|
80
|
-
const proc = spawn(binaryPath,
|
|
102
|
+
const proc = spawn(binaryPath, allArgs, {
|
|
81
103
|
stdio: 'inherit',
|
|
82
104
|
});
|
|
83
105
|
proc.on('error', (err) => {
|
|
@@ -89,3 +111,56 @@ export async function lint({ rebuild, tsgolintVersion, passthroughArgs, }) {
|
|
|
89
111
|
});
|
|
90
112
|
});
|
|
91
113
|
}
|
|
114
|
+
/** Build --warn flags for rules with severity 'warn'.
|
|
115
|
+
* Uses goRuleName (parsed from Go source) to match the runtime name
|
|
116
|
+
* that tsgolint uses in diagnostics, avoiding silent mismatches. */
|
|
117
|
+
function buildWarnArgs(rules) {
|
|
118
|
+
const args = [];
|
|
119
|
+
for (const rule of rules) {
|
|
120
|
+
if (rule.severity === 'warn') {
|
|
121
|
+
args.push('--warn', rule.goRuleName);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return args;
|
|
125
|
+
}
|
|
126
|
+
/** Get git-changed files and build --warn-file flags so warnings only
|
|
127
|
+
* appear for new/modified code. Returns [] if git is unavailable or not
|
|
128
|
+
* a git repo — the runner will then show no warnings (safe default).
|
|
129
|
+
* Linting must never crash from this. */
|
|
130
|
+
async function buildWarnFileArgs() {
|
|
131
|
+
try {
|
|
132
|
+
// Get git repo root to resolve relative paths to absolute.
|
|
133
|
+
const topLevelResult = await execAsync('git', ['rev-parse', '--show-toplevel'], { stdio: 'pipe' }).catch(() => null);
|
|
134
|
+
if (!topLevelResult)
|
|
135
|
+
return [];
|
|
136
|
+
const repoRoot = topLevelResult.stdout.trim();
|
|
137
|
+
// Changed files (staged + unstaged vs HEAD)
|
|
138
|
+
const diffResult = await execAsync('git', ['diff', '--name-only', 'HEAD'], { stdio: 'pipe' }).catch(() => null);
|
|
139
|
+
// Untracked files (new files not yet committed)
|
|
140
|
+
const untrackedResult = await execAsync('git', ['ls-files', '--others', '--exclude-standard'], { stdio: 'pipe' }).catch(() => null);
|
|
141
|
+
const files = new Set();
|
|
142
|
+
for (const result of [diffResult, untrackedResult]) {
|
|
143
|
+
if (!result)
|
|
144
|
+
continue;
|
|
145
|
+
for (const line of result.stdout.split('\n')) {
|
|
146
|
+
const trimmed = line.trim();
|
|
147
|
+
if (trimmed) {
|
|
148
|
+
// Resolve to absolute path so it matches SourceFile.FileName() in the runner.
|
|
149
|
+
files.add(path.resolve(repoRoot, trimmed));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// No changed files → no --warn-file flags → runner shows no warnings (clean tree)
|
|
154
|
+
if (files.size === 0)
|
|
155
|
+
return [];
|
|
156
|
+
const args = [];
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
args.push('--warn-file', file);
|
|
159
|
+
}
|
|
160
|
+
return args;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// git not installed, not a repo, or any other failure — no warnings shown
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,IAAI,IAAI,
|
|
1
|
+
{"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,IAAI,IAAI,CA6BhC"}
|
package/dist/commands/list.js
CHANGED
|
@@ -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) => {
|
|
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
|
|
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
|
}
|