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/dist/discover.d.ts
CHANGED
|
@@ -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
|
package/dist/discover.d.ts.map
CHANGED
|
@@ -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;
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The shadcn for type-aware TypeScript lint rules. Browse, pick, and copy rules into your project.",
|
|
6
6
|
"bin": "dist/cli.js",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"tar": "^7.5.12"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
|
-
"build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js"
|
|
63
|
+
"build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
|
|
64
|
+
"cli": "tsx src/cli"
|
|
64
65
|
}
|
|
65
66
|
}
|
package/src/cache.ts
CHANGED
|
@@ -16,9 +16,9 @@ import { extract } from 'tar'
|
|
|
16
16
|
import { execAsync } from './exec.ts'
|
|
17
17
|
|
|
18
18
|
// Pinned tsgolint fork commit — updated with each lintcn release.
|
|
19
|
-
// Uses remorses/tsgolint fork which adds internal/runner.Run()
|
|
20
|
-
//
|
|
21
|
-
export const DEFAULT_TSGOLINT_VERSION = '
|
|
19
|
+
// Uses remorses/tsgolint fork which adds internal/runner.Run() and
|
|
20
|
+
// TSGOLINT_SNAPSHOT_CWD env var for cwd-relative test snapshots.
|
|
21
|
+
export const DEFAULT_TSGOLINT_VERSION = '518fa0d395effb07a45070643a0cb1a9cf202ce9'
|
|
22
22
|
|
|
23
23
|
// Pinned typescript-go base commit from microsoft/typescript-go (before patches).
|
|
24
24
|
// Patches from tsgolint/patches/ are applied on top during setup.
|
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { listRules } from './commands/list.ts'
|
|
|
11
11
|
import { removeRule } from './commands/remove.ts'
|
|
12
12
|
import { clean } from './commands/clean.ts'
|
|
13
13
|
import { DEFAULT_TSGOLINT_VERSION } from './cache.ts'
|
|
14
|
+
import { findLintcnDir } from './paths.ts'
|
|
14
15
|
|
|
15
16
|
const require = createRequire(import.meta.url)
|
|
16
17
|
const packageJson = require('../package.json') as { version: string }
|
|
@@ -18,11 +19,13 @@ const packageJson = require('../package.json') as { version: string }
|
|
|
18
19
|
const cli = goke('lintcn')
|
|
19
20
|
|
|
20
21
|
cli
|
|
21
|
-
.command('add <url>', 'Add
|
|
22
|
-
.example('# Add a rule folder')
|
|
22
|
+
.command('add <url>', 'Add rules by GitHub URL. Supports single rule folders, .lintcn/ directories, or full repo URLs.')
|
|
23
|
+
.example('# Add a single rule folder')
|
|
23
24
|
.example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
|
|
24
25
|
.example('# Add by file URL (auto-fetches the whole folder)')
|
|
25
26
|
.example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
|
|
27
|
+
.example('# Add all rules from a repo (downloads .lintcn/ folder)')
|
|
28
|
+
.example('lintcn add https://github.com/someone/their-project')
|
|
26
29
|
.action(async (url) => {
|
|
27
30
|
await addRule(url)
|
|
28
31
|
})
|
|
@@ -43,12 +46,17 @@ cli
|
|
|
43
46
|
cli
|
|
44
47
|
.command('lint', 'Build custom tsgolint binary and run it against the project')
|
|
45
48
|
.option('--rebuild', 'Force rebuild even if cached binary exists')
|
|
49
|
+
.option('--fix', 'Automatically fix violations')
|
|
46
50
|
.option('--tsconfig <path>', 'Path to tsconfig.json')
|
|
47
51
|
.option('--list-files', 'List matched files')
|
|
52
|
+
.option('--all-warnings', 'Show warnings for all files, not just git-changed ones')
|
|
48
53
|
.option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
|
|
49
54
|
.action(async (options) => {
|
|
50
55
|
const tsgolintVersion = (options.tsgolintVersion as string) || DEFAULT_TSGOLINT_VERSION
|
|
51
56
|
const passthroughArgs: string[] = []
|
|
57
|
+
if (options.fix) {
|
|
58
|
+
passthroughArgs.push('--fix')
|
|
59
|
+
}
|
|
52
60
|
if (options.tsconfig) {
|
|
53
61
|
passthroughArgs.push('--tsconfig', options.tsconfig as string)
|
|
54
62
|
}
|
|
@@ -64,6 +72,7 @@ cli
|
|
|
64
72
|
rebuild: !!options.rebuild,
|
|
65
73
|
tsgolintVersion,
|
|
66
74
|
passthroughArgs,
|
|
75
|
+
allWarnings: !!options.allWarnings,
|
|
67
76
|
})
|
|
68
77
|
process.exit(exitCode)
|
|
69
78
|
})
|
|
@@ -73,6 +82,10 @@ cli
|
|
|
73
82
|
.option('--rebuild', 'Force rebuild even if cached binary exists')
|
|
74
83
|
.option('--tsgolint-version [version]', 'Override the pinned tsgolint version (tag or commit). For testing unreleased tsgolint versions.')
|
|
75
84
|
.action(async (options) => {
|
|
85
|
+
if (!findLintcnDir()) {
|
|
86
|
+
console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.')
|
|
87
|
+
return
|
|
88
|
+
}
|
|
76
89
|
const tsgolintVersion = (options.tsgolintVersion as string) || DEFAULT_TSGOLINT_VERSION
|
|
77
90
|
const binaryPath = await buildBinary({ rebuild: !!options.rebuild, tsgolintVersion })
|
|
78
91
|
console.log(binaryPath)
|
package/src/commands/add.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
28
|
-
let
|
|
29
|
-
|
|
30
|
-
const
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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,
|
|
198
|
+
content = ensureSourceComment(content, sourceUrl)
|
|
174
199
|
}
|
|
175
200
|
|
|
176
201
|
fs.writeFileSync(path.join(ruleDir, item.name), content)
|
|
177
|
-
console.log(`
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/lint.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// Handles Go workspace generation, compilation with caching, and execution.
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
5
6
|
import { spawn } from 'node:child_process'
|
|
6
|
-
import { requireLintcnDir } from '../paths.ts'
|
|
7
|
-
import { discoverRules } from '../discover.ts'
|
|
7
|
+
import { requireLintcnDir, findLintcnDir } from '../paths.ts'
|
|
8
|
+
import { discoverRules, type RuleMetadata } from '../discover.ts'
|
|
8
9
|
import { generateBuildWorkspace, generateEditorGoFiles } from '../codegen.ts'
|
|
9
10
|
import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
|
|
10
11
|
import { computeContentHash } from '../hash.ts'
|
|
@@ -99,16 +100,41 @@ export async function lint({
|
|
|
99
100
|
rebuild,
|
|
100
101
|
tsgolintVersion,
|
|
101
102
|
passthroughArgs,
|
|
103
|
+
allWarnings,
|
|
102
104
|
}: {
|
|
103
105
|
rebuild: boolean
|
|
104
106
|
tsgolintVersion: string
|
|
105
107
|
passthroughArgs: string[]
|
|
108
|
+
allWarnings: boolean
|
|
106
109
|
}): Promise<number> {
|
|
110
|
+
if (!findLintcnDir()) {
|
|
111
|
+
console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.')
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
107
115
|
const binaryPath = await buildBinary({ rebuild, tsgolintVersion })
|
|
108
116
|
|
|
109
|
-
//
|
|
117
|
+
// Discover rules to inject --warn flags for warning-severity rules.
|
|
118
|
+
// buildBinary already discovered rules for compilation, but we need the
|
|
119
|
+
// metadata here to know which rules are warnings at runtime.
|
|
120
|
+
const lintcnDir = requireLintcnDir()
|
|
121
|
+
const rules = discoverRules(lintcnDir)
|
|
122
|
+
const warnArgs = buildWarnArgs(rules)
|
|
123
|
+
|
|
124
|
+
// By default, limit warnings to git-changed files so they don't flood
|
|
125
|
+
// the output in large codebases. --all-warnings bypasses this filter.
|
|
126
|
+
const hasWarnRules = rules.some((r) => r.severity === 'warn')
|
|
127
|
+
let warnFileArgs: string[] = []
|
|
128
|
+
if (hasWarnRules && allWarnings) {
|
|
129
|
+
warnFileArgs = ['--all-warnings']
|
|
130
|
+
} else if (hasWarnRules) {
|
|
131
|
+
warnFileArgs = await buildWarnFileArgs()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// run the binary with --warn + --warn-file/--all-warnings flags + passthrough args
|
|
135
|
+
const allArgs = [...warnArgs, ...warnFileArgs, ...passthroughArgs]
|
|
110
136
|
return new Promise((resolve) => {
|
|
111
|
-
const proc = spawn(binaryPath,
|
|
137
|
+
const proc = spawn(binaryPath, allArgs, {
|
|
112
138
|
stdio: 'inherit',
|
|
113
139
|
})
|
|
114
140
|
|
|
@@ -122,3 +148,59 @@ export async function lint({
|
|
|
122
148
|
})
|
|
123
149
|
})
|
|
124
150
|
}
|
|
151
|
+
|
|
152
|
+
/** Build --warn flags for rules with severity 'warn'.
|
|
153
|
+
* Uses goRuleName (parsed from Go source) to match the runtime name
|
|
154
|
+
* that tsgolint uses in diagnostics, avoiding silent mismatches. */
|
|
155
|
+
function buildWarnArgs(rules: RuleMetadata[]): string[] {
|
|
156
|
+
const args: string[] = []
|
|
157
|
+
for (const rule of rules) {
|
|
158
|
+
if (rule.severity === 'warn') {
|
|
159
|
+
args.push('--warn', rule.goRuleName)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return args
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Get git-changed files and build --warn-file flags so warnings only
|
|
166
|
+
* appear for new/modified code. Returns [] if git is unavailable or not
|
|
167
|
+
* a git repo — the runner will then show no warnings (safe default).
|
|
168
|
+
* Linting must never crash from this. */
|
|
169
|
+
async function buildWarnFileArgs(): Promise<string[]> {
|
|
170
|
+
try {
|
|
171
|
+
// Get git repo root to resolve relative paths to absolute.
|
|
172
|
+
const topLevelResult = await execAsync('git', ['rev-parse', '--show-toplevel'], { stdio: 'pipe' }).catch(() => null)
|
|
173
|
+
if (!topLevelResult) return []
|
|
174
|
+
const repoRoot = topLevelResult.stdout.trim()
|
|
175
|
+
|
|
176
|
+
// Changed files (staged + unstaged vs HEAD)
|
|
177
|
+
const diffResult = await execAsync('git', ['diff', '--name-only', 'HEAD'], { stdio: 'pipe' }).catch(() => null)
|
|
178
|
+
// Untracked files (new files not yet committed)
|
|
179
|
+
const untrackedResult = await execAsync('git', ['ls-files', '--others', '--exclude-standard'], { stdio: 'pipe' }).catch(() => null)
|
|
180
|
+
|
|
181
|
+
const files = new Set<string>()
|
|
182
|
+
|
|
183
|
+
for (const result of [diffResult, untrackedResult]) {
|
|
184
|
+
if (!result) continue
|
|
185
|
+
for (const line of result.stdout.split('\n')) {
|
|
186
|
+
const trimmed = line.trim()
|
|
187
|
+
if (trimmed) {
|
|
188
|
+
// Resolve to absolute path so it matches SourceFile.FileName() in the runner.
|
|
189
|
+
files.add(path.resolve(repoRoot, trimmed))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// No changed files → no --warn-file flags → runner shows no warnings (clean tree)
|
|
195
|
+
if (files.size === 0) return []
|
|
196
|
+
|
|
197
|
+
const args: string[] = []
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
args.push('--warn-file', file)
|
|
200
|
+
}
|
|
201
|
+
return args
|
|
202
|
+
} catch {
|
|
203
|
+
// git not installed, not a repo, or any other failure — no warnings shown
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/commands/list.ts
CHANGED
|
@@ -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) => {
|
|
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
|
|
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
|
}
|