lintcn 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/README.md +123 -23
- package/dist/cache.d.ts +1 -1
- package/dist/cache.js +1 -1
- package/dist/cli.js +19 -5
- package/dist/codegen.js +20 -3
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +223 -77
- package/dist/commands/clean.d.ts +2 -0
- package/dist/commands/clean.d.ts.map +1 -0
- package/dist/commands/clean.js +44 -0
- package/dist/commands/lint.d.ts +2 -1
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +93 -7
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +5 -2
- package/dist/commands/remove.d.ts.map +1 -1
- package/dist/commands/remove.js +6 -14
- package/dist/discover.d.ts +13 -3
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +49 -23
- package/dist/hash.d.ts +7 -1
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js +28 -25
- package/package.json +3 -2
- package/src/cache.ts +1 -1
- package/src/cli.ts +20 -5
- package/src/codegen.ts +22 -3
- package/src/commands/add.ts +270 -73
- package/src/commands/clean.ts +48 -0
- package/src/commands/lint.ts +100 -7
- package/src/commands/list.ts +5 -2
- package/src/commands/remove.ts +6 -16
- package/src/discover.ts +66 -31
- package/src/hash.ts +33 -27
package/dist/commands/add.js
CHANGED
|
@@ -1,60 +1,126 @@
|
|
|
1
|
-
// lintcn add <url> — fetch a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// lintcn add <url> — fetch a rule folder by URL and copy into .lintcn/{rule_name}/
|
|
2
|
+
// Supports GitHub folder URLs (/tree/) and file URLs (/blob/).
|
|
3
|
+
// For file URLs, auto-detects the parent folder and fetches all sibling files.
|
|
4
|
+
// Uses GitHub API to list folder contents.
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
6
8
|
import { getLintcnDir } from "../paths.js";
|
|
7
9
|
import { generateEditorGoFiles } from "../codegen.js";
|
|
8
10
|
import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from "../cache.js";
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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. */
|
|
15
|
+
function parseGitHubUrl(url) {
|
|
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);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
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('/');
|
|
32
|
+
return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) };
|
|
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
|
+
}
|
|
54
|
+
return null;
|
|
29
55
|
}
|
|
30
|
-
|
|
31
|
-
|
|
56
|
+
/** Get a GitHub auth token from gh CLI, GITHUB_TOKEN env, or return undefined. */
|
|
57
|
+
function getGitHubToken() {
|
|
58
|
+
if (process.env.GITHUB_TOKEN) {
|
|
59
|
+
return process.env.GITHUB_TOKEN;
|
|
60
|
+
}
|
|
61
|
+
// Try gh CLI token (synchronous to keep it simple)
|
|
62
|
+
try {
|
|
63
|
+
return execSync('gh auth token', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
32
68
|
}
|
|
33
|
-
|
|
34
|
-
|
|
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 });
|
|
35
81
|
if (!response.ok) {
|
|
36
|
-
throw new Error(`
|
|
82
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`);
|
|
37
83
|
}
|
|
38
|
-
|
|
84
|
+
const data = (await response.json());
|
|
85
|
+
return data.default_branch;
|
|
39
86
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
]);
|
|
91
|
+
/** List files in a GitHub directory via the Contents API. */
|
|
92
|
+
async function listGitHubFolder(owner, repo, dirPath, ref) {
|
|
93
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`;
|
|
94
|
+
const headers = {
|
|
95
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
96
|
+
'User-Agent': 'lintcn',
|
|
97
|
+
};
|
|
98
|
+
const token = getGitHubToken();
|
|
99
|
+
if (token) {
|
|
100
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
43
101
|
}
|
|
44
|
-
|
|
45
|
-
|
|
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();
|
|
107
|
+
if (!Array.isArray(data)) {
|
|
108
|
+
throw new Error(`Expected a directory listing from GitHub API but got a single file.\n ${apiUrl}`);
|
|
46
109
|
}
|
|
110
|
+
return data;
|
|
47
111
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
112
|
+
async function fetchFile(url) {
|
|
113
|
+
const response = await fetch(url);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
116
|
+
}
|
|
117
|
+
return response.text();
|
|
52
118
|
}
|
|
53
119
|
function ensureSourceComment(content, sourceUrl) {
|
|
54
120
|
if (content.includes('// lintcn:source')) {
|
|
55
121
|
return content;
|
|
56
122
|
}
|
|
57
|
-
// Insert source comment after
|
|
123
|
+
// Insert source comment after any existing lintcn: comment block, or at the very top
|
|
58
124
|
const lines = content.split('\n');
|
|
59
125
|
let insertIndex = 0;
|
|
60
126
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -68,54 +134,134 @@ function ensureSourceComment(content, sourceUrl) {
|
|
|
68
134
|
lines.splice(insertIndex, 0, `// lintcn:source ${sourceUrl}`);
|
|
69
135
|
return lines.join('\n');
|
|
70
136
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
throw new Error(`URL must point to a .go file, got: ${fileName}`);
|
|
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) {
|
|
141
|
+
const folderName = path.posix.basename(dirPath);
|
|
142
|
+
const items = await listGitHubFolder(owner, repo, dirPath, ref);
|
|
143
|
+
const filesToFetch = items.filter((item) => {
|
|
144
|
+
return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'));
|
|
145
|
+
});
|
|
146
|
+
if (filesToFetch.length === 0) {
|
|
147
|
+
console.warn(` Skipping ${folderName}/ — no .go files found`);
|
|
148
|
+
return false;
|
|
84
149
|
}
|
|
85
|
-
const
|
|
86
|
-
fs.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
// ensure .tsgolint source is available and generate editor support files
|
|
150
|
+
const ruleDir = path.join(lintcnDir, folderName);
|
|
151
|
+
if (fs.existsSync(ruleDir)) {
|
|
152
|
+
fs.rmSync(ruleDir, { recursive: true });
|
|
153
|
+
console.log(` Overwriting existing ${folderName}/`);
|
|
154
|
+
}
|
|
155
|
+
fs.mkdirSync(ruleDir, { recursive: true });
|
|
156
|
+
for (const item of filesToFetch) {
|
|
157
|
+
let content = await fetchFile(item.download_url);
|
|
158
|
+
if (item.name === `${folderName}.go`) {
|
|
159
|
+
content = ensureSourceComment(content, sourceUrl);
|
|
160
|
+
}
|
|
161
|
+
fs.writeFileSync(path.join(ruleDir, item.name), content);
|
|
162
|
+
console.log(` ${item.name}`);
|
|
163
|
+
}
|
|
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) {
|
|
106
169
|
const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION);
|
|
107
|
-
// create .tsgolint symlink inside .lintcn for gopls.
|
|
108
|
-
// Use lstatSync to detect broken symlinks (existsSync returns false for broken links)
|
|
109
170
|
const tsgolintLink = path.join(lintcnDir, '.tsgolint');
|
|
110
171
|
try {
|
|
111
172
|
fs.lstatSync(tsgolintLink);
|
|
112
|
-
// exists (possibly broken) — remove and recreate
|
|
113
173
|
fs.rmSync(tsgolintLink, { force: true });
|
|
114
174
|
}
|
|
115
175
|
catch {
|
|
116
|
-
// doesn't exist
|
|
176
|
+
// doesn't exist
|
|
117
177
|
}
|
|
118
178
|
fs.symlinkSync(tsgolintDir, tsgolintLink);
|
|
119
179
|
generateEditorGoFiles(lintcnDir);
|
|
120
180
|
console.log('Editor support files generated (go.work, go.mod)');
|
|
121
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clean.d.ts","sourceRoot":"","sources":["../../src/commands/clean.ts"],"names":[],"mappings":"AAMA,wBAAgB,KAAK,IAAI,IAAI,CAW5B"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// lintcn clean — remove cached tsgolint source and compiled binaries.
|
|
2
|
+
// Frees disk space from old versions that accumulate over time.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { getCacheDir } from "../cache.js";
|
|
5
|
+
export function clean() {
|
|
6
|
+
const cacheDir = getCacheDir();
|
|
7
|
+
if (!fs.existsSync(cacheDir)) {
|
|
8
|
+
console.log('No cache to clean');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const stats = getCacheStats(cacheDir);
|
|
12
|
+
fs.rmSync(cacheDir, { recursive: true });
|
|
13
|
+
console.log(`Removed ${cacheDir} (${formatBytes(stats.totalBytes)})`);
|
|
14
|
+
}
|
|
15
|
+
function getCacheStats(dir) {
|
|
16
|
+
let totalBytes = 0;
|
|
17
|
+
const walk = (d) => {
|
|
18
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
19
|
+
const fullPath = `${d}/${entry.name}`;
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
walk(fullPath);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
totalBytes += fs.statSync(fullPath).size;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
walk(dir);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// ignore errors during stat
|
|
33
|
+
}
|
|
34
|
+
return { totalBytes };
|
|
35
|
+
}
|
|
36
|
+
function formatBytes(bytes) {
|
|
37
|
+
if (bytes < 1024) {
|
|
38
|
+
return `${bytes}B`;
|
|
39
|
+
}
|
|
40
|
+
if (bytes < 1024 * 1024) {
|
|
41
|
+
return `${(bytes / 1024).toFixed(0)}KB`;
|
|
42
|
+
}
|
|
43
|
+
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
|
44
|
+
}
|
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,CAoClB"}
|
package/dist/commands/lint.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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";
|
|
7
|
-
import { generateBuildWorkspace } from "../codegen.js";
|
|
8
|
+
import { generateBuildWorkspace, generateEditorGoFiles } from "../codegen.js";
|
|
8
9
|
import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
|
|
9
10
|
import { computeContentHash } from "../hash.js";
|
|
10
11
|
import { execAsync } from "../exec.js";
|
|
@@ -29,7 +30,7 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
|
|
|
29
30
|
// ensure tsgolint source
|
|
30
31
|
const tsgolintDir = await ensureTsgolintSource(tsgolintVersion);
|
|
31
32
|
// compute content hash
|
|
32
|
-
const contentHash = await computeContentHash({
|
|
33
|
+
const { short: contentHash } = await computeContentHash({
|
|
33
34
|
lintcnDir,
|
|
34
35
|
tsgolintVersion,
|
|
35
36
|
});
|
|
@@ -38,6 +39,8 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
|
|
|
38
39
|
console.log('Using cached binary');
|
|
39
40
|
return getBinaryPath(contentHash);
|
|
40
41
|
}
|
|
42
|
+
// ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
|
|
43
|
+
generateEditorGoFiles(lintcnDir);
|
|
41
44
|
// generate build workspace (per-hash dir to avoid races between concurrent processes)
|
|
42
45
|
const buildDir = getBuildDir(contentHash);
|
|
43
46
|
console.log('Generating build workspace...');
|
|
@@ -51,18 +54,48 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
|
|
|
51
54
|
const binDir = getBinDir();
|
|
52
55
|
fs.mkdirSync(binDir, { recursive: true });
|
|
53
56
|
const binaryPath = getBinaryPath(contentHash);
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
// Check if any lintcn binary has been built before — if not, this is a cold
|
|
58
|
+
// build that compiles the full tsgolint + typescript-go dependency tree.
|
|
59
|
+
const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : [];
|
|
60
|
+
if (existingBins.length === 0) {
|
|
61
|
+
console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...');
|
|
62
|
+
console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).');
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.log('Compiling custom tsgolint binary...');
|
|
66
|
+
}
|
|
67
|
+
const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', binaryPath, './wrapper'], {
|
|
56
68
|
cwd: buildDir,
|
|
69
|
+
stdio: 'inherit',
|
|
57
70
|
});
|
|
71
|
+
if (buildExitCode !== 0) {
|
|
72
|
+
throw new Error(`Go compilation failed (exit code ${buildExitCode})`);
|
|
73
|
+
}
|
|
58
74
|
console.log('Build complete');
|
|
59
75
|
return binaryPath;
|
|
60
76
|
}
|
|
61
|
-
export async function lint({ rebuild, tsgolintVersion, passthroughArgs, }) {
|
|
77
|
+
export async function lint({ rebuild, tsgolintVersion, passthroughArgs, allWarnings, }) {
|
|
62
78
|
const binaryPath = await buildBinary({ rebuild, tsgolintVersion });
|
|
63
|
-
//
|
|
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];
|
|
64
97
|
return new Promise((resolve) => {
|
|
65
|
-
const proc = spawn(binaryPath,
|
|
98
|
+
const proc = spawn(binaryPath, allArgs, {
|
|
66
99
|
stdio: 'inherit',
|
|
67
100
|
});
|
|
68
101
|
proc.on('error', (err) => {
|
|
@@ -74,3 +107,56 @@ export async function lint({ rebuild, tsgolintVersion, passthroughArgs, }) {
|
|
|
74
107
|
});
|
|
75
108
|
});
|
|
76
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,
|
|
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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../../src/commands/remove.ts"],"names":[],"mappings":"AAOA,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../../src/commands/remove.ts"],"names":[],"mappings":"AAOA,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAmB7C"}
|
package/dist/commands/remove.js
CHANGED
|
@@ -1,28 +1,20 @@
|
|
|
1
|
-
// lintcn remove <name> — delete a rule
|
|
1
|
+
// lintcn remove <name> — delete a rule subfolder from .lintcn/
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { requireLintcnDir } from "../paths.js";
|
|
5
5
|
import { discoverRules } from "../discover.js";
|
|
6
6
|
export function removeRule(name) {
|
|
7
7
|
const lintcnDir = requireLintcnDir();
|
|
8
|
-
// match by lintcn:name metadata or by filename
|
|
9
8
|
const rules = discoverRules(lintcnDir);
|
|
10
9
|
const normalizedName = name.replace(/-/g, '_');
|
|
11
10
|
const match = rules.find((r) => {
|
|
12
|
-
return r.name === name || r.
|
|
11
|
+
return r.name === name || r.packageName === normalizedName;
|
|
13
12
|
});
|
|
14
13
|
if (!match) {
|
|
15
14
|
throw new Error(`Rule "${name}" not found. Run \`lintcn list\` to see installed rules.`);
|
|
16
15
|
}
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
fs.rmSync(
|
|
20
|
-
console.log(`Removed ${match.
|
|
21
|
-
// delete test file if exists
|
|
22
|
-
const testFileName = match.fileName.replace(/\.go$/, '_test.go');
|
|
23
|
-
const testPath = path.join(lintcnDir, testFileName);
|
|
24
|
-
if (fs.existsSync(testPath)) {
|
|
25
|
-
fs.rmSync(testPath);
|
|
26
|
-
console.log(`Removed ${testFileName}`);
|
|
27
|
-
}
|
|
16
|
+
// Remove the entire subfolder
|
|
17
|
+
const ruleDir = path.join(lintcnDir, match.packageName);
|
|
18
|
+
fs.rmSync(ruleDir, { recursive: true });
|
|
19
|
+
console.log(`Removed ${match.packageName}/`);
|
|
28
20
|
}
|
package/dist/discover.d.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
export interface RuleMetadata {
|
|
2
|
-
/** kebab-case rule name from // lintcn:name or derived from
|
|
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
|
-
/**
|
|
11
|
-
|
|
17
|
+
/** Go package name (= subfolder name, e.g. no_floating_promises) */
|
|
18
|
+
packageName: string;
|
|
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
|