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.
@@ -1,60 +1,126 @@
1
- // lintcn add <url> — fetch a .go rule file by URL and copy into .lintcn/
2
- // Also tries to fetch matching _test.go file from the same directory.
3
- // Normalizes GitHub blob URLs to raw URLs automatically.
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
- /** Convert GitHub blob URLs to raw.githubusercontent.com.
10
- * Handles branch names containing slashes (e.g. feature/x) by splitting
11
- * on /blob/ then finding the file path from the end (must end in .go). */
12
- function normalizeGithubUrl(url) {
13
- const blobSplit = url.match(/^(https?:\/\/github\.com\/[^/]+\/[^/]+)\/blob\/(.+)$/);
14
- if (!blobSplit) {
15
- return url;
16
- }
17
- const [, repoUrl, refAndPath] = blobSplit;
18
- // repoUrl = "https://github.com/owner/repo"
19
- // refAndPath = "feature/x/rules/my_rule.go" or "main/rules/my_rule.go"
20
- // Extract owner/repo from repoUrl
21
- const repoMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)$/);
22
- if (!repoMatch) {
23
- return url;
24
- }
25
- const [, owner, repo] = repoMatch;
26
- // For raw.githubusercontent.com, the format is owner/repo/ref/path.
27
- // We can pass refAndPath directly since GitHub resolves it.
28
- return `https://raw.githubusercontent.com/${owner}/${repo}/${refAndPath}`;
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
- function deriveTestUrl(rawUrl) {
31
- return rawUrl.replace(/\.go$/, '_test.go');
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
- async function fetchFile(url) {
34
- const response = await fetch(url);
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(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
82
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`);
37
83
  }
38
- return response.text();
84
+ const data = (await response.json());
85
+ return data.default_branch;
39
86
  }
40
- async function tryFetchFile(url) {
41
- try {
42
- return await fetchFile(url);
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
- catch {
45
- return null;
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 rewritePackageName(content) {
49
- // Rewrite first package declaration to package lintcn.
50
- // Only matches before the first import or func to avoid touching comments.
51
- return content.replace(/^package\s+\w+/m, 'package lintcn');
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 the first lintcn: comment block, or at the very top
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
- export async function addRule(url) {
72
- const rawUrl = normalizeGithubUrl(url);
73
- console.log(`Fetching ${rawUrl}...`);
74
- const content = await fetchFile(rawUrl);
75
- // validate it looks like a Go file with a rule
76
- if (!content.includes('rule.Rule')) {
77
- console.warn('Warning: no rule.Rule reference found in this file. Are you sure this is a tsgolint rule?');
78
- }
79
- // derive filename from URL
80
- const urlPath = new URL(rawUrl).pathname;
81
- const fileName = path.basename(urlPath);
82
- if (!fileName.endsWith('.go')) {
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 lintcnDir = getLintcnDir();
86
- fs.mkdirSync(lintcnDir, { recursive: true });
87
- // write the rule file
88
- const filePath = path.join(lintcnDir, fileName);
89
- if (fs.existsSync(filePath)) {
90
- console.log(`Overwriting existing ${fileName}`);
91
- }
92
- let processed = rewritePackageName(content);
93
- processed = ensureSourceComment(processed, url);
94
- fs.writeFileSync(filePath, processed);
95
- console.log(`Added ${fileName}`);
96
- // try to fetch matching test file
97
- const testUrl = deriveTestUrl(rawUrl);
98
- const testContent = await tryFetchFile(testUrl);
99
- if (testContent) {
100
- const testFileName = fileName.replace(/\.go$/, '_test.go');
101
- const testProcessed = rewritePackageName(testContent);
102
- fs.writeFileSync(path.join(lintcnDir, testFileName), testProcessed);
103
- console.log(`Added ${testFileName}`);
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 at all
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,2 @@
1
+ export declare function clean(): void;
2
+ //# sourceMappingURL=clean.d.ts.map
@@ -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
+ }
@@ -2,9 +2,10 @@ export declare function buildBinary({ rebuild, tsgolintVersion, }: {
2
2
  rebuild: boolean;
3
3
  tsgolintVersion: string;
4
4
  }): Promise<string>;
5
- export declare function lint({ rebuild, tsgolintVersion, passthroughArgs, }: {
5
+ export declare function lint({ rebuild, tsgolintVersion, passthroughArgs, allWarnings, }: {
6
6
  rebuild: boolean;
7
7
  tsgolintVersion: string;
8
8
  passthroughArgs: string[];
9
+ allWarnings: boolean;
9
10
  }): Promise<number>;
10
11
  //# sourceMappingURL=lint.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/commands/lint.ts"],"names":[],"mappings":"AAuBA,wBAAsB,WAAW,CAAC,EAChC,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkDlB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;CAC1B,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBlB"}
1
+ {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/commands/lint.ts"],"names":[],"mappings":"AAwBA,wBAAsB,WAAW,CAAC,EAChC,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkElB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,EACf,WAAW,GACZ,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoClB"}
@@ -1,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
- console.log('Compiling custom tsgolint binary...');
55
- await execAsync('go', ['build', '-o', binaryPath, './wrapper'], {
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
- // run the binary with passthrough args, inheriting stdio
79
+ // Discover rules to inject --warn flags for warning-severity rules.
80
+ // buildBinary already discovered rules for compilation, but we need the
81
+ // metadata here to know which rules are warnings at runtime.
82
+ const lintcnDir = requireLintcnDir();
83
+ const rules = discoverRules(lintcnDir);
84
+ const warnArgs = buildWarnArgs(rules);
85
+ // By default, limit warnings to git-changed files so they don't flood
86
+ // the output in large codebases. --all-warnings bypasses this filter.
87
+ const hasWarnRules = rules.some((r) => r.severity === 'warn');
88
+ let warnFileArgs = [];
89
+ if (hasWarnRules && allWarnings) {
90
+ warnFileArgs = ['--all-warnings'];
91
+ }
92
+ else if (hasWarnRules) {
93
+ warnFileArgs = await buildWarnFileArgs();
94
+ }
95
+ // run the binary with --warn + --warn-file/--all-warnings flags + passthrough args
96
+ const allArgs = [...warnArgs, ...warnFileArgs, ...passthroughArgs];
64
97
  return new Promise((resolve) => {
65
- const proc = spawn(binaryPath, passthroughArgs, {
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,CA0BhC"}
1
+ {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,IAAI,IAAI,CA6BhC"}
@@ -13,9 +13,12 @@ export function listRules() {
13
13
  return;
14
14
  }
15
15
  console.log('Installed rules:\n');
16
- const maxNameLen = Math.max(...rules.map((r) => { return r.name.length; }));
16
+ const maxNameLen = Math.max(...rules.map((r) => {
17
+ return r.name.length + (r.severity === 'warn' ? 7 : 0); // ' (warn)' = 7 chars
18
+ }));
17
19
  for (const rule of rules) {
18
- const name = rule.name.padEnd(maxNameLen + 2);
20
+ const suffix = rule.severity === 'warn' ? ' (warn)' : '';
21
+ const name = (rule.name + suffix).padEnd(maxNameLen + 2);
19
22
  const desc = rule.description || '(no description)';
20
23
  console.log(` ${name}${desc}`);
21
24
  }
@@ -1 +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,CA6B7C"}
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"}
@@ -1,28 +1,20 @@
1
- // lintcn remove <name> — delete a rule and its test file from .lintcn/
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.fileName.replace(/\.go$/, '') === normalizedName;
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
- // delete rule file
18
- const rulePath = path.join(lintcnDir, match.fileName);
19
- fs.rmSync(rulePath);
20
- console.log(`Removed ${match.fileName}`);
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
  }
@@ -1,16 +1,26 @@
1
1
  export interface RuleMetadata {
2
- /** kebab-case rule name from // lintcn:name or derived from filename */
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
- /** filename relative to .lintcn/ */
11
- fileName: string;
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