lintcn 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,55 @@
1
+ ## 0.6.0
2
+
3
+ 1. **Rules now live in subfolders** — each rule is its own Go package under `.lintcn/{rule_name}/`, replacing the old flat `.lintcn/*.go` layout. This eliminates the need to rename `options.go` and `schema.json` companions — they stay in the subfolder with their original names, and the Go package name matches the folder. `lintcn add` now fetches the entire rule folder automatically.
4
+
5
+ ```
6
+ .lintcn/
7
+ no_floating_promises/
8
+ no_floating_promises.go
9
+ no_floating_promises_test.go
10
+ options.go ← original name, no renaming
11
+ schema.json
12
+ my_custom_rule/
13
+ my_custom_rule.go
14
+ ```
15
+
16
+ 2. **`lintcn add` fetches whole folders** — both folder URLs (`/tree/`) and file URLs (`/blob/`) now fetch every `.go` and `.json` file in the rule's directory. Passing a file URL auto-detects the parent folder:
17
+
18
+ ```bash
19
+ # folder URL
20
+ lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises
21
+
22
+ # file URL — auto-fetches the whole folder
23
+ lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go
24
+ ```
25
+
26
+ 3. **Error for flat `.go` files in `.lintcn/`** — if leftover flat files from older versions are detected, lintcn now prints a clear migration error with instructions instead of silently ignoring them.
27
+
28
+ 4. **Reproducible builds with `-trimpath`** — the Go binary is now built with `-trimpath`, stripping absolute paths from the output. Binaries are identical across machines for the same rule content + tsgolint version + platform.
29
+
30
+ 5. **Faster cache hits** — Go version removed from the content hash. The compiled binary is a standalone executable with no Go runtime dependency, so the Go version used to build it doesn't affect correctness. Also excludes `_test.go` files from the hash since tests don't affect the binary.
31
+
32
+ 6. **Go compilation output is live** — `go build` now inherits stdio, so compilation progress and errors stream directly to the terminal instead of being silently captured.
33
+
34
+ 7. **First-build guidance** — on first compile (cold Go cache), lintcn explains the one-time 30s cost and shows which directories to cache in CI:
35
+ ```
36
+ Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...
37
+ Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).
38
+ ```
39
+
40
+ 8. **GitHub Actions example** — README now includes a copy-paste workflow that caches the compiled binary. Subsequent CI runs take ~12s (vs ~4min cold):
41
+
42
+ ```yaml
43
+ - name: Cache lintcn binary + Go build cache
44
+ uses: actions/cache@v4
45
+ with:
46
+ path: |
47
+ ~/.cache/lintcn
48
+ ~/go/pkg
49
+ key: lintcn-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.lintcn/**/*.go') }}
50
+ restore-keys: lintcn-${{ runner.os }}-${{ runner.arch }}-
51
+ ```
52
+
1
53
  ## 0.5.0
2
54
 
3
55
  1. **Security fix — path traversal in `--tsgolint-version`** — the version flag is now validated against a strict pattern. Previously a value like `../../etc` could escape the cache directory.
package/README.md CHANGED
@@ -13,8 +13,11 @@ npm install -D lintcn
13
13
  ## Usage
14
14
 
15
15
  ```bash
16
- # Add a rule by URL
17
- npx lintcn add https://github.com/user/repo/blob/main/rules/no_unhandled_error.go
16
+ # Add a rule folder from tsgolint
17
+ npx lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises
18
+
19
+ # Add by file URL (auto-fetches the whole folder)
20
+ npx lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go
18
21
 
19
22
  # Lint your project
20
23
  npx lintcn lint
@@ -26,21 +29,33 @@ npx lintcn lint --tsconfig tsconfig.build.json
26
29
  npx lintcn list
27
30
 
28
31
  # Remove a rule
29
- npx lintcn remove no-unhandled-error
32
+ npx lintcn remove no-floating-promises
33
+
34
+ # Clean cached tsgolint source + binaries
35
+ npx lintcn clean
30
36
  ```
31
37
 
38
+ Browse all 50+ available built-in rules in the [tsgolint rules directory](https://github.com/oxc-project/tsgolint/tree/main/internal/rules).
39
+
32
40
  ## How it works
33
41
 
34
- Rules live as `.go` files in `.lintcn/` at your project root. You own the source — edit, customize, delete.
42
+ Each rule lives in its own subfolder under `.lintcn/`. You own the source — edit, customize, delete.
35
43
 
36
44
  ```
37
45
  my-project/
38
46
  ├── .lintcn/
39
- │ ├── .gitignore ← ignores generated Go files
40
- │ ├── no_unhandled_error.go ← your rule (committed)
41
- └── no_unhandled_error_test.go its tests (committed)
47
+ │ ├── .gitignore ← ignores generated Go files
48
+ │ ├── no_floating_promises/
49
+ │ ├── no_floating_promises.go rule source (committed)
50
+ │ │ ├── no_floating_promises_test.go ← tests (committed)
51
+ │ │ ├── options.go ← rule options struct
52
+ │ │ └── schema.json ← options schema
53
+ │ ├── await_thenable/
54
+ │ │ ├── await_thenable.go
55
+ │ │ └── await_thenable_test.go
56
+ │ └── my_custom_rule/
57
+ │ └── my_custom_rule.go
42
58
  ├── src/
43
- │ ├── index.ts
44
59
  │ └── ...
45
60
  ├── tsconfig.json
46
61
  └── package.json
@@ -48,24 +63,32 @@ my-project/
48
63
 
49
64
  When you run `npx lintcn lint`, the CLI:
50
65
 
51
- 1. Scans `.lintcn/*.go` for rule definitions
66
+ 1. Scans `.lintcn/*/` subfolders for rule definitions
52
67
  2. Generates a Go workspace with your custom rules
53
68
  3. Compiles a custom binary (cached — rebuilds only when rules change)
54
69
  4. Runs the binary against your project
55
70
 
56
71
  You can run `lintcn lint` from any subdirectory — it walks up to find `.lintcn/` and lints the cwd project.
57
72
 
58
- ## Writing a rule
73
+ ## Writing custom rules
59
74
 
60
- Every rule is a Go file with `package lintcn` that exports a `rule.Rule` variable.
75
+ To help AI agents write and modify rules, install the lintcn skill:
76
+
77
+ ```bash
78
+ npx skills add remorses/lintcn
79
+ ```
61
80
 
62
- Here's a rule that errors when you discard the return value of a function that returns `Error | T` enforcing the [errore](https://errore.org) pattern:
81
+ This gives your AI agent the full tsgolint rule API reference AST visitors, type checker, reporting, fixes, and testing patterns.
82
+
83
+ Every rule lives in a subfolder under `.lintcn/` with the package name matching the folder:
63
84
 
64
85
  ```go
86
+ // .lintcn/no_unhandled_error/no_unhandled_error.go
87
+
65
88
  // lintcn:name no-unhandled-error
66
89
  // lintcn:description Disallow discarding Error-typed return values
67
90
 
68
- package lintcn
91
+ package no_unhandled_error
69
92
 
70
93
  import (
71
94
  "github.com/microsoft/typescript-go/shim/ast"
@@ -136,7 +159,7 @@ void getUser("id")
136
159
  ```json
137
160
  {
138
161
  "devDependencies": {
139
- "lintcn": "0.4.0"
162
+ "lintcn": "0.5.0"
140
163
  }
141
164
  }
142
165
  ```
@@ -147,12 +170,41 @@ Each lintcn release bundles a specific tsgolint version. Updating lintcn can cha
147
170
  2. Run `npx lintcn build` after updating to verify your rules still compile
148
171
  3. Fix any compilation errors before committing
149
172
 
150
- You can test against an unreleased tsgolint version without updating lintcn:
151
-
152
- ```bash
153
- npx lintcn lint --tsgolint-version v0.10.0
173
+ ## CI Setup
174
+
175
+ The first `lintcn lint` compiles a custom Go binary (~30s). Subsequent runs use the cached binary (<1s). Cache `~/.cache/lintcn/` and Go's build cache to keep CI fast.
176
+
177
+ ```yaml
178
+ # .github/workflows/lint.yml
179
+ name: Lint
180
+ on: [push, pull_request]
181
+
182
+ jobs:
183
+ lint:
184
+ runs-on: ubuntu-latest
185
+ steps:
186
+ - uses: actions/checkout@v4
187
+
188
+ - uses: actions/setup-node@v4
189
+ with:
190
+ node-version: 22
191
+
192
+ - name: Cache lintcn binary + Go build cache
193
+ uses: actions/cache@v4
194
+ with:
195
+ path: |
196
+ ~/.cache/lintcn
197
+ ~/go/pkg
198
+ key: lintcn-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.lintcn/**/*.go') }}
199
+ restore-keys: |
200
+ lintcn-${{ runner.os }}-${{ runner.arch }}-
201
+
202
+ - run: npm ci
203
+ - run: npx lintcn lint
154
204
  ```
155
205
 
206
+ The cache key includes a hash of your rule files — when rules change, the binary is recompiled. The `restore-keys` fallback ensures Go's build cache is still used even when rules change, so recompilation takes ~1s instead of 30s.
207
+
156
208
  ## Prerequisites
157
209
 
158
210
  - **Node.js** — for the CLI
package/dist/cli.js CHANGED
@@ -7,16 +7,17 @@ import { addRule } from "./commands/add.js";
7
7
  import { lint, buildBinary } from "./commands/lint.js";
8
8
  import { listRules } from "./commands/list.js";
9
9
  import { removeRule } from "./commands/remove.js";
10
+ import { clean } from "./commands/clean.js";
10
11
  import { DEFAULT_TSGOLINT_VERSION } from "./cache.js";
11
12
  const require = createRequire(import.meta.url);
12
13
  const packageJson = require('../package.json');
13
14
  const cli = goke('lintcn');
14
15
  cli
15
- .command('add <url>', 'Add a rule by URL. Fetches the .go file and copies it into .lintcn/')
16
- .example('# Add a rule from GitHub')
17
- .example('lintcn add https://github.com/user/repo/blob/main/rules/no_floating_promises.go')
18
- .example('# Add from raw URL')
19
- .example('lintcn add https://raw.githubusercontent.com/user/repo/main/rules/no_unused_result.go')
16
+ .command('add <url>', 'Add a rule by GitHub URL. Fetches the whole folder into .lintcn/{rule}/')
17
+ .example('# Add a rule folder')
18
+ .example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
19
+ .example('# Add by file URL (auto-fetches the whole folder)')
20
+ .example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
20
21
  .action(async (url) => {
21
22
  await addRule(url);
22
23
  });
@@ -67,6 +68,11 @@ cli
67
68
  const binaryPath = await buildBinary({ rebuild: !!options.rebuild, tsgolintVersion });
68
69
  console.log(binaryPath);
69
70
  });
71
+ cli
72
+ .command('clean', 'Remove cached tsgolint source and compiled binaries to free disk space')
73
+ .action(() => {
74
+ clean();
75
+ });
70
76
  cli.help();
71
77
  cli.version(packageJson.version);
72
78
  cli.parse();
package/dist/codegen.js CHANGED
@@ -102,10 +102,27 @@ go 1.26
102
102
  const mainGo = generateMainGo(rules);
103
103
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo);
104
104
  }
105
- /** Generate main.go that imports user rules and calls internal/runner.Run(). */
105
+ /** Sanitize a package name into a valid Go identifier for use as an import alias.
106
+ * Replaces hyphens/dots with underscores, prepends _ if starts with a digit. */
107
+ function toGoAlias(pkg) {
108
+ let alias = pkg.replace(/[^a-zA-Z0-9_]/g, '_');
109
+ if (/^[0-9]/.test(alias)) {
110
+ alias = '_' + alias;
111
+ }
112
+ return alias;
113
+ }
114
+ /** Generate main.go that imports user rules and calls internal/runner.Run().
115
+ * Each rule subfolder is its own Go package, imported by package name. */
106
116
  function generateMainGo(rules) {
117
+ // Deduplicate imports by package name (in case a subfolder has multiple rules)
118
+ const uniquePackages = [...new Set(rules.map((r) => { return r.packageName; }))];
119
+ const imports = uniquePackages.map((pkg) => {
120
+ const alias = toGoAlias(pkg);
121
+ return `\t${alias} "${TSGOLINT_MODULE}/lintcn-rules/${pkg}"`;
122
+ }).join('\n');
107
123
  const ruleEntries = rules.map((r) => {
108
- return `\t\tlintcn.${r.varName},`;
124
+ const alias = toGoAlias(r.packageName);
125
+ return `\t\t${alias}.${r.varName},`;
109
126
  }).join('\n');
110
127
  return `// Code generated by lintcn. DO NOT EDIT.
111
128
  package main
@@ -115,7 +132,7 @@ import (
115
132
 
116
133
  \t"${TSGOLINT_MODULE}/internal/rule"
117
134
  \t"${TSGOLINT_MODULE}/internal/runner"
118
- \tlintcn "${TSGOLINT_MODULE}/lintcn-rules"
135
+ ${imports}
119
136
  )
120
137
 
121
138
  func main() {
@@ -1 +1 @@
1
- {"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AA+EA,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA2DxD"}
1
+ {"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AAwHA,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4ExD"}
@@ -1,34 +1,70 @@
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 GitHub blob/tree/raw URLs into components.
12
+ * Ref is assumed to be the first path component after blob/tree
13
+ * branch names with slashes (e.g. feature/foo) are not supported. */
14
+ function parseGitHubUrl(url) {
15
+ // GitHub blob URLs: github.com/owner/repo/blob/ref/path/to/file.go
16
+ let match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
17
+ if (match) {
18
+ const [, owner, repo, ref, filePath] = match;
19
+ return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) };
20
+ }
21
+ // GitHub tree URLs: github.com/owner/repo/tree/ref/path/to/folder
22
+ match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/);
23
+ if (match) {
24
+ const [, owner, repo, ref, dirPath] = match;
25
+ return { owner, repo, ref, dirPath };
26
+ }
27
+ // raw.githubusercontent.com URLs
28
+ match = url.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
29
+ if (match) {
30
+ const [, owner, repo, ref, filePath] = match;
31
+ return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) };
32
+ }
33
+ return null;
34
+ }
35
+ /** Get a GitHub auth token from gh CLI, GITHUB_TOKEN env, or return undefined. */
36
+ function getGitHubToken() {
37
+ if (process.env.GITHUB_TOKEN) {
38
+ return process.env.GITHUB_TOKEN;
39
+ }
40
+ // Try gh CLI token (synchronous to keep it simple)
41
+ try {
42
+ return execSync('gh auth token', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || undefined;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
29
47
  }
30
- function deriveTestUrl(rawUrl) {
31
- return rawUrl.replace(/\.go$/, '_test.go');
48
+ /** List files in a GitHub directory via the Contents API. */
49
+ async function listGitHubFolder(owner, repo, dirPath, ref) {
50
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`;
51
+ const headers = {
52
+ 'Accept': 'application/vnd.github.v3+json',
53
+ 'User-Agent': 'lintcn',
54
+ };
55
+ const token = getGitHubToken();
56
+ if (token) {
57
+ headers['Authorization'] = `Bearer ${token}`;
58
+ }
59
+ const response = await fetch(apiUrl, { headers });
60
+ if (!response.ok) {
61
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`);
62
+ }
63
+ const data = await response.json();
64
+ if (!Array.isArray(data)) {
65
+ throw new Error(`Expected a directory listing from GitHub API but got a single file.\n ${apiUrl}`);
66
+ }
67
+ return data;
32
68
  }
33
69
  async function fetchFile(url) {
34
70
  const response = await fetch(url);
@@ -37,24 +73,11 @@ async function fetchFile(url) {
37
73
  }
38
74
  return response.text();
39
75
  }
40
- async function tryFetchFile(url) {
41
- try {
42
- return await fetchFile(url);
43
- }
44
- catch {
45
- return null;
46
- }
47
- }
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');
52
- }
53
76
  function ensureSourceComment(content, sourceUrl) {
54
77
  if (content.includes('// lintcn:source')) {
55
78
  return content;
56
79
  }
57
- // Insert source comment after the first lintcn: comment block, or at the very top
80
+ // Insert source comment after any existing lintcn: comment block, or at the very top
58
81
  const lines = content.split('\n');
59
82
  let insertIndex = 0;
60
83
  for (let i = 0; i < lines.length; i++) {
@@ -69,51 +92,59 @@ function ensureSourceComment(content, sourceUrl) {
69
92
  return lines.join('\n');
70
93
  }
71
94
  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}`);
95
+ const parsed = parseGitHubUrl(url);
96
+ if (!parsed) {
97
+ throw new Error('Only GitHub URLs are supported. Pass a /blob/ (file) or /tree/ (folder) URL.\n' +
98
+ 'Example: lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises');
99
+ }
100
+ const { owner, repo, ref, dirPath } = parsed;
101
+ const folderName = path.posix.basename(dirPath);
102
+ console.log(`Fetching ${owner}/${repo}/${dirPath}...`);
103
+ const items = await listGitHubFolder(owner, repo, dirPath, ref);
104
+ // Filter for .go and .json files
105
+ const filesToFetch = items.filter((item) => {
106
+ return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'));
107
+ });
108
+ if (filesToFetch.length === 0) {
109
+ throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`);
110
+ }
111
+ // Warn if this doesn't look like a single-rule folder (too many main .go files)
112
+ const mainGoFiles = filesToFetch.filter((f) => {
113
+ return f.name.endsWith('.go') && !f.name.endsWith('_test.go') && f.name !== 'options.go';
114
+ });
115
+ if (mainGoFiles.length > 3) {
116
+ console.warn(`Warning: folder has ${mainGoFiles.length} non-test .go files. ` +
117
+ `This may be a directory of multiple rules — consider using a more specific URL.`);
84
118
  }
85
119
  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
120
+ const ruleDir = path.join(lintcnDir, folderName);
121
+ // Clean existing rule folder if it exists
122
+ if (fs.existsSync(ruleDir)) {
123
+ fs.rmSync(ruleDir, { recursive: true });
124
+ console.log(`Overwriting existing ${folderName}/`);
125
+ }
126
+ fs.mkdirSync(ruleDir, { recursive: true });
127
+ // Fetch and write all files
128
+ for (const item of filesToFetch) {
129
+ let content = await fetchFile(item.download_url);
130
+ // Add lintcn:source comment to the main rule file (same name as folder)
131
+ if (item.name === `${folderName}.go`) {
132
+ content = ensureSourceComment(content, url);
133
+ }
134
+ fs.writeFileSync(path.join(ruleDir, item.name), content);
135
+ console.log(` ${item.name}`);
136
+ }
137
+ console.log(`Added ${folderName}/ (${filesToFetch.length} files)`);
138
+ // Ensure tsgolint source is available
106
139
  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)
140
+ // Create/refresh .tsgolint symlink for gopls
109
141
  const tsgolintLink = path.join(lintcnDir, '.tsgolint');
110
142
  try {
111
143
  fs.lstatSync(tsgolintLink);
112
- // exists (possibly broken) — remove and recreate
113
144
  fs.rmSync(tsgolintLink, { force: true });
114
145
  }
115
146
  catch {
116
- // doesn't exist at all
147
+ // doesn't exist
117
148
  }
118
149
  fs.symlinkSync(tsgolintDir, tsgolintLink);
119
150
  generateEditorGoFiles(lintcnDir);
@@ -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
+ }
@@ -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":"AAuBA,wBAAsB,WAAW,CAAC,EAChC,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkElB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;CAC1B,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBlB"}
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { requireLintcnDir } from "../paths.js";
6
6
  import { discoverRules } from "../discover.js";
7
- import { generateBuildWorkspace } from "../codegen.js";
7
+ import { generateBuildWorkspace, generateEditorGoFiles } from "../codegen.js";
8
8
  import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
9
9
  import { computeContentHash } from "../hash.js";
10
10
  import { execAsync } from "../exec.js";
@@ -29,7 +29,7 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
29
29
  // ensure tsgolint source
30
30
  const tsgolintDir = await ensureTsgolintSource(tsgolintVersion);
31
31
  // compute content hash
32
- const contentHash = await computeContentHash({
32
+ const { short: contentHash } = await computeContentHash({
33
33
  lintcnDir,
34
34
  tsgolintVersion,
35
35
  });
@@ -38,6 +38,8 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
38
38
  console.log('Using cached binary');
39
39
  return getBinaryPath(contentHash);
40
40
  }
41
+ // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
42
+ generateEditorGoFiles(lintcnDir);
41
43
  // generate build workspace (per-hash dir to avoid races between concurrent processes)
42
44
  const buildDir = getBuildDir(contentHash);
43
45
  console.log('Generating build workspace...');
@@ -51,10 +53,23 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
51
53
  const binDir = getBinDir();
52
54
  fs.mkdirSync(binDir, { recursive: true });
53
55
  const binaryPath = getBinaryPath(contentHash);
54
- console.log('Compiling custom tsgolint binary...');
55
- await execAsync('go', ['build', '-o', binaryPath, './wrapper'], {
56
+ // Check if any lintcn binary has been built before — if not, this is a cold
57
+ // build that compiles the full tsgolint + typescript-go dependency tree.
58
+ const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : [];
59
+ if (existingBins.length === 0) {
60
+ console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...');
61
+ console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).');
62
+ }
63
+ else {
64
+ console.log('Compiling custom tsgolint binary...');
65
+ }
66
+ const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', binaryPath, './wrapper'], {
56
67
  cwd: buildDir,
68
+ stdio: 'inherit',
57
69
  });
70
+ if (buildExitCode !== 0) {
71
+ throw new Error(`Go compilation failed (exit code ${buildExitCode})`);
72
+ }
58
73
  console.log('Build complete');
59
74
  return binaryPath;
60
75
  }
@@ -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"}