lintcn 0.4.0 → 0.5.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,14 +1,18 @@
1
- ## 0.4.0
1
+ ## 0.5.0
2
2
 
3
- 1. **Simpler rule imports** rules now import from `pkg/rule`, `pkg/utils`, etc. instead of `internal/rule`. The `internal/` child-module-path hack is gone. Your `.go` files use clean import paths:
4
- ```go
5
- import (
6
- "github.com/typescript-eslint/tsgolint/pkg/rule"
7
- "github.com/typescript-eslint/tsgolint/pkg/utils"
8
- )
9
- ```
3
+ 1. **Security fixpath traversal in `--tsgolint-version`** the version flag is now validated against a strict pattern. Previously a value like `../../etc` could escape the cache directory.
4
+
5
+ 2. **Fixed intermittent failures with concurrent `lintcn lint` runs** — build workspaces are now per-content-hash instead of shared. Two processes running simultaneously no longer corrupt each other's build.
6
+
7
+ 3. **Cross-platform tar extraction** — replaced shell `tar` command with the npm `tar` package. Works on Windows without needing system tar.
10
8
 
11
- 2. **Simpler codegen** — uses a tsgolint fork with `pkg/runner.Run()`, eliminating all regex surgery on main.go. The generated binary entry point is a 15-line template instead of a patched copy of tsgolint's main.go.
9
+ 4. **No more `patch` command required** — tsgolint downloads now use a fork with a clean `internal/runner.Run()` entry point. Zero modifications to existing tsgolint files; upstream syncs will never conflict.
10
+
11
+ 5. **Downloads no longer hang** — 120s timeout on all GitHub tarball downloads.
12
+
13
+ 6. **Fixed broken `.tsgolint` symlink** — `lintcn add` now correctly detects and recreates broken symlinks.
14
+
15
+ ## 0.4.0
12
16
 
13
17
  ## 0.3.0
14
18
 
package/README.md CHANGED
@@ -49,10 +49,12 @@ my-project/
49
49
  When you run `npx lintcn lint`, the CLI:
50
50
 
51
51
  1. Scans `.lintcn/*.go` for rule definitions
52
- 2. Generates a Go workspace with all 50+ built-in tsgolint rules + your custom rules
52
+ 2. Generates a Go workspace with your custom rules
53
53
  3. Compiles a custom binary (cached — rebuilds only when rules change)
54
54
  4. Runs the binary against your project
55
55
 
56
+ You can run `lintcn lint` from any subdirectory — it walks up to find `.lintcn/` and lints the cwd project.
57
+
56
58
  ## Writing a rule
57
59
 
58
60
  Every rule is a Go file with `package lintcn` that exports a `rule.Rule` variable.
@@ -134,7 +136,7 @@ void getUser("id")
134
136
  ```json
135
137
  {
136
138
  "devDependencies": {
137
- "lintcn": "0.1.0"
139
+ "lintcn": "0.4.0"
138
140
  }
139
141
  }
140
142
  ```
@@ -154,8 +156,7 @@ npx lintcn lint --tsgolint-version v0.10.0
154
156
  ## Prerequisites
155
157
 
156
158
  - **Node.js** — for the CLI
157
- - **Go 1.26+** — for compiling rules (`go.dev/dl`)
158
- - **Git** — for cloning tsgolint source on first build
159
+ - **Go** — for compiling rules (`go.dev/dl`)
159
160
 
160
161
  Go is only needed for `lintcn lint` / `lintcn build`. Adding and listing rules works without Go.
161
162
 
package/dist/cache.d.ts CHANGED
@@ -1,9 +1,13 @@
1
- export declare const DEFAULT_TSGOLINT_VERSION = "a93604379da2631b70332a65bc47eb5ced689a3b";
1
+ export declare const DEFAULT_TSGOLINT_VERSION = "e945641eabec22993eda3e7c101692e80417e0ea";
2
+ /** Validate version string to prevent path traversal attacks.
3
+ * Only allows alphanumeric chars, dots, underscores, and hyphens. */
4
+ export declare function validateVersion(version: string): void;
2
5
  export declare function getCacheDir(): string;
3
6
  export declare function getTsgolintSourceDir(version: string): string;
4
7
  export declare function getBinDir(): string;
5
8
  export declare function getBinaryPath(contentHash: string): string;
6
- export declare function getBuildDir(): string;
9
+ /** Per-hash build directory to avoid races between concurrent lintcn processes. */
10
+ export declare function getBuildDir(contentHash: string): string;
7
11
  export declare function ensureTsgolintSource(version: string): Promise<string>;
8
12
  export declare function cachedBinaryExists(contentHash: string): boolean;
9
13
  //# sourceMappingURL=cache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,wBAAwB,6CAA6C,CAAA;AAQlF,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAqCD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2D3E;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAQ/D"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,wBAAwB,6CAA6C,CAAA;AAUlF;sEACsE;AACtE,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAOrD;AAED,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAED,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAEvD;AA0DD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuE3E;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAQ/D"}
package/dist/cache.js CHANGED
@@ -4,21 +4,33 @@
4
4
  //
5
5
  // Cache layout:
6
6
  // ~/.cache/lintcn/tsgolint/<version>/ — extracted source (read-only)
7
+ // ~/.cache/lintcn/build/<content-hash>/ — per-hash build workspace (no race)
7
8
  // ~/.cache/lintcn/bin/<content-hash> — compiled binaries
8
9
  import fs from 'node:fs';
9
10
  import os from 'node:os';
10
11
  import path from 'node:path';
12
+ import crypto from 'node:crypto';
11
13
  import { pipeline } from 'node:stream/promises';
14
+ import { extract } from 'tar';
12
15
  import { execAsync } from "./exec.js";
13
16
  // Pinned tsgolint fork commit — updated with each lintcn release.
14
- // Uses remorses/tsgolint fork which exposes pkg/runner.Run() and
15
- // moves internal/ packages to pkg/ for clean external imports.
16
- export const DEFAULT_TSGOLINT_VERSION = 'a93604379da2631b70332a65bc47eb5ced689a3b';
17
+ // Uses remorses/tsgolint fork which adds internal/runner.Run().
18
+ // Only 1 commit on top of upstream zero modifications to existing files.
19
+ export const DEFAULT_TSGOLINT_VERSION = 'e945641eabec22993eda3e7c101692e80417e0ea';
17
20
  // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
18
21
  // Patches from tsgolint/patches/ are applied on top during setup.
19
- // This is the upstream commit the tsgolint submodule was forked from.
20
22
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
21
23
  const TYPESCRIPT_GO_COMMIT = '1b7eabe122e1575a0df9c77eccdf4e063c623224';
24
+ // Strict pattern for version strings — prevents path traversal via ../
25
+ const VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
26
+ /** Validate version string to prevent path traversal attacks.
27
+ * Only allows alphanumeric chars, dots, underscores, and hyphens. */
28
+ export function validateVersion(version) {
29
+ if (!VERSION_PATTERN.test(version)) {
30
+ throw new Error(`Invalid tsgolint version "${version}". ` +
31
+ 'Version must only contain alphanumeric characters, dots, underscores, and hyphens.');
32
+ }
33
+ }
22
34
  export function getCacheDir() {
23
35
  return path.join(os.homedir(), '.cache', 'lintcn');
24
36
  }
@@ -31,24 +43,47 @@ export function getBinDir() {
31
43
  export function getBinaryPath(contentHash) {
32
44
  return path.join(getBinDir(), contentHash);
33
45
  }
34
- export function getBuildDir() {
35
- return path.join(getCacheDir(), 'build');
46
+ /** Per-hash build directory to avoid races between concurrent lintcn processes. */
47
+ export function getBuildDir(contentHash) {
48
+ return path.join(getCacheDir(), 'build', contentHash);
36
49
  }
37
50
  /** Download a tarball from URL and extract it to targetDir.
51
+ * Uses the `tar` npm package for cross-platform support (no shell tar needed).
38
52
  * GitHub tarballs have a top-level directory like `repo-ref/`,
39
53
  * so we strip the first path component during extraction. */
40
54
  async function downloadAndExtract(url, targetDir) {
41
- const response = await fetch(url);
55
+ const controller = new AbortController();
56
+ const timeout = setTimeout(() => {
57
+ controller.abort(new Error(`Download timed out after 120s: ${url}`));
58
+ }, 120_000);
59
+ let response;
60
+ try {
61
+ response = await fetch(url, { signal: controller.signal });
62
+ }
63
+ finally {
64
+ clearTimeout(timeout);
65
+ }
42
66
  if (!response.ok || !response.body) {
43
67
  throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
44
68
  }
45
- fs.mkdirSync(targetDir, { recursive: true });
46
- const tmpTarGz = path.join(os.tmpdir(), `lintcn-${Date.now()}.tar.gz`);
47
- const fileStream = fs.createWriteStream(tmpTarGz);
48
- // @ts-ignore ReadableStream vs NodeJS.ReadableStream mismatch
49
- await pipeline(response.body, fileStream);
50
- await execAsync('tar', ['xzf', tmpTarGz, '--strip-components=1', '-C', targetDir]);
51
- fs.rmSync(tmpTarGz, { force: true });
69
+ // download to temp file with random suffix to avoid collisions
70
+ const tmpTarGz = path.join(os.tmpdir(), `lintcn-${crypto.randomBytes(8).toString('hex')}.tar.gz`);
71
+ try {
72
+ const fileStream = fs.createWriteStream(tmpTarGz);
73
+ // @ts-ignore ReadableStream vs NodeJS.ReadableStream mismatch
74
+ await pipeline(response.body, fileStream);
75
+ // extract with npm tar package (cross-platform, no shell tar needed)
76
+ fs.mkdirSync(targetDir, { recursive: true });
77
+ await extract({
78
+ file: tmpTarGz,
79
+ cwd: targetDir,
80
+ strip: 1,
81
+ });
82
+ }
83
+ finally {
84
+ // always clean up temp file
85
+ fs.rmSync(tmpTarGz, { force: true });
86
+ }
52
87
  }
53
88
  /** Apply git-format patches using `patch -p1` (no git required).
54
89
  * Patches are standard unified diff format, `patch` ignores the git metadata. */
@@ -63,12 +98,17 @@ async function applyPatches(patchesDir, targetDir) {
63
98
  return patches.length;
64
99
  }
65
100
  export async function ensureTsgolintSource(version) {
101
+ validateVersion(version);
66
102
  const sourceDir = getTsgolintSourceDir(version);
67
103
  const readyMarker = path.join(sourceDir, '.lintcn-ready');
68
104
  if (fs.existsSync(readyMarker)) {
69
105
  return sourceDir;
70
106
  }
71
- // clean up any partial previous attempt so we start fresh
107
+ // Use a temp directory for the download, then atomic rename on success.
108
+ // This prevents concurrent processes from seeing partial state, and
109
+ // avoids the "non-empty dir on retry" problem.
110
+ const tmpDir = path.join(getCacheDir(), 'tsgolint', `.tmp-${version}-${crypto.randomBytes(4).toString('hex')}`);
111
+ // clean up any partial previous attempt
72
112
  if (fs.existsSync(sourceDir)) {
73
113
  fs.rmSync(sourceDir, { recursive: true });
74
114
  }
@@ -76,14 +116,14 @@ export async function ensureTsgolintSource(version) {
76
116
  // download tsgolint fork tarball
77
117
  console.log(`Downloading tsgolint@${version.slice(0, 8)}...`);
78
118
  const tsgolintUrl = `https://github.com/remorses/tsgolint/archive/${version}.tar.gz`;
79
- await downloadAndExtract(tsgolintUrl, sourceDir);
119
+ await downloadAndExtract(tsgolintUrl, tmpDir);
80
120
  // download typescript-go from microsoft (base commit before patches)
81
- const tsGoDir = path.join(sourceDir, 'typescript-go');
121
+ const tsGoDir = path.join(tmpDir, 'typescript-go');
82
122
  console.log('Downloading typescript-go...');
83
123
  const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`;
84
124
  await downloadAndExtract(tsGoUrl, tsGoDir);
85
125
  // apply tsgolint's patches to typescript-go
86
- const patchesDir = path.join(sourceDir, 'patches');
126
+ const patchesDir = path.join(tmpDir, 'patches');
87
127
  if (fs.existsSync(patchesDir)) {
88
128
  const count = await applyPatches(patchesDir, tsGoDir);
89
129
  if (count > 0) {
@@ -91,7 +131,7 @@ export async function ensureTsgolintSource(version) {
91
131
  }
92
132
  }
93
133
  // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
94
- const collectionsDir = path.join(sourceDir, 'internal', 'collections');
134
+ const collectionsDir = path.join(tmpDir, 'internal', 'collections');
95
135
  const tsGoCollections = path.join(tsGoDir, 'internal', 'collections');
96
136
  if (fs.existsSync(tsGoCollections)) {
97
137
  fs.mkdirSync(collectionsDir, { recursive: true });
@@ -103,13 +143,16 @@ export async function ensureTsgolintSource(version) {
103
143
  }
104
144
  }
105
145
  // write ready marker
106
- fs.writeFileSync(readyMarker, new Date().toISOString());
146
+ fs.writeFileSync(path.join(tmpDir, '.lintcn-ready'), new Date().toISOString());
147
+ // atomic rename: move completed dir to final location
148
+ fs.mkdirSync(path.dirname(sourceDir), { recursive: true });
149
+ fs.renameSync(tmpDir, sourceDir);
107
150
  console.log('tsgolint source ready');
108
151
  }
109
152
  catch (err) {
110
- // clean up partial download so next run starts fresh
111
- if (fs.existsSync(sourceDir)) {
112
- fs.rmSync(sourceDir, { recursive: true });
153
+ // clean up partial temp directory
154
+ if (fs.existsSync(tmpDir)) {
155
+ fs.rmSync(tmpDir, { recursive: true });
113
156
  }
114
157
  throw err;
115
158
  }
package/dist/codegen.d.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import type { RuleMetadata } from './discover.ts';
2
2
  /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support. */
3
3
  export declare function generateEditorGoFiles(lintcnDir: string): void;
4
- /** Generate build workspace for compiling the custom binary.
5
- * With pkg/runner.Run(), the generated main.go is a static template —
6
- * no regex surgery or file copying needed. */
4
+ /** Generate build workspace for compiling the custom binary. */
7
5
  export declare function generateBuildWorkspace({ buildDir, tsgolintDir, lintcnDir, rules, }: {
8
6
  buildDir: string;
9
7
  tsgolintDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../src/codegen.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AA2BjD,4EAA4E;AAC5E,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAkC7D;AAED;;+CAE+C;AAC/C,wBAAgB,sBAAsB,CAAC,EACrC,QAAQ,EACR,WAAW,EACX,SAAS,EACT,KAAK,GACN,EAAE;IACD,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,YAAY,EAAE,CAAA;CACtB,GAAG,IAAI,CA2CP"}
1
+ {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../src/codegen.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AA4BjD,4EAA4E;AAC5E,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAmC7D;AAED,gEAAgE;AAChE,wBAAgB,sBAAsB,CAAC,EACrC,QAAQ,EACR,WAAW,EACX,SAAS,EACT,KAAK,GACN,EAAE;IACD,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,YAAY,EAAE,CAAA;CACtB,GAAG,IAAI,CA2CP"}
package/dist/codegen.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // Generate Go workspace files for building a custom tsgolint binary.
2
- // With the fork (remorses/tsgolint) exposing pkg/runner.Run(), codegen is
3
- // minimal: a 10-line main.go template + go.work with shim replaces.
4
- // No regex surgery, no file copying, no fragile string manipulation.
2
+ // Uses internal/runner.Run() from the fork — codegen is just a static
3
+ // main.go template + go.work with shim replaces.
4
+ //
5
+ // Key: module names must be child paths of github.com/typescript-eslint/tsgolint
6
+ // so Go allows importing internal/ packages across the module boundary.
5
7
  import fs from 'node:fs';
6
8
  import path from 'node:path';
7
9
  // Shim modules that need replace directives in go.work.
8
- // These redirect module paths to local directories inside the tsgolint source.
9
10
  const SHIM_MODULES = [
10
11
  'ast',
11
12
  'bundled',
@@ -22,6 +23,7 @@ const SHIM_MODULES = [
22
23
  'vfs/cachedvfs',
23
24
  'vfs/osvfs',
24
25
  ];
26
+ const TSGOLINT_MODULE = 'github.com/typescript-eslint/tsgolint';
25
27
  function generateReplaceDirectives(tsgolintRelPath) {
26
28
  return SHIM_MODULES.map((mod) => {
27
29
  return `\tgithub.com/microsoft/typescript-go/shim/${mod} => ${tsgolintRelPath}/shim/${mod}`;
@@ -41,8 +43,9 @@ replace (
41
43
  ${generateReplaceDirectives('./.tsgolint')}
42
44
  )
43
45
  `;
44
- // No child-path hack neededpkg/ is public, any module name works
45
- const goMod = `module lintcn-rules
46
+ // Module name is a child path of tsgolintthis is required so Go allows
47
+ // importing internal/ packages across the module boundary in a workspace.
48
+ const goMod = `module ${TSGOLINT_MODULE}/lintcn-rules
46
49
 
47
50
  go 1.26
48
51
  `;
@@ -59,9 +62,7 @@ go.sum
59
62
  fs.writeFileSync(gitignorePath, gitignore);
60
63
  }
61
64
  }
62
- /** Generate build workspace for compiling the custom binary.
63
- * With pkg/runner.Run(), the generated main.go is a static template —
64
- * no regex surgery or file copying needed. */
65
+ /** Generate build workspace for compiling the custom binary. */
65
66
  export function generateBuildWorkspace({ buildDir, tsgolintDir, lintcnDir, rules, }) {
66
67
  fs.mkdirSync(path.join(buildDir, 'wrapper'), { recursive: true });
67
68
  // symlink tsgolint source
@@ -91,18 +92,17 @@ ${generateReplaceDirectives('./tsgolint')}
91
92
  )
92
93
  `;
93
94
  fs.writeFileSync(path.join(buildDir, 'go.work'), goWork);
94
- // wrapper/go.modsimple module name, no child-path hack needed
95
- const wrapperGoMod = `module lintcn-wrapper
95
+ // wrapper module child path of tsgolint for internal/ access
96
+ const wrapperGoMod = `module ${TSGOLINT_MODULE}/lintcn-wrapper
96
97
 
97
98
  go 1.26
98
99
  `;
99
100
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'go.mod'), wrapperGoMod);
100
- // wrapper/main.go — simple template, no regex or string surgery
101
+ // wrapper/main.go — static template
101
102
  const mainGo = generateMainGo(rules);
102
103
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo);
103
104
  }
104
- /** Generate a minimal main.go that imports user rules and calls runner.Run().
105
- * This is a static template — no copying or patching of tsgolint source. */
105
+ /** Generate main.go that imports user rules and calls internal/runner.Run(). */
106
106
  function generateMainGo(rules) {
107
107
  const ruleEntries = rules.map((r) => {
108
108
  return `\t\tlintcn.${r.varName},`;
@@ -113,9 +113,9 @@ package main
113
113
  import (
114
114
  \t"os"
115
115
 
116
- \t"github.com/typescript-eslint/tsgolint/pkg/rule"
117
- \t"github.com/typescript-eslint/tsgolint/pkg/runner"
118
- \tlintcn "lintcn-rules"
116
+ \t"${TSGOLINT_MODULE}/internal/rule"
117
+ \t"${TSGOLINT_MODULE}/internal/runner"
118
+ \tlintcn "${TSGOLINT_MODULE}/lintcn-rules"
119
119
  )
120
120
 
121
121
  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,CAqDxD"}
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"}
@@ -104,11 +104,18 @@ export async function addRule(url) {
104
104
  }
105
105
  // ensure .tsgolint source is available and generate editor support files
106
106
  const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION);
107
- // create .tsgolint symlink inside .lintcn for gopls
107
+ // create .tsgolint symlink inside .lintcn for gopls.
108
+ // Use lstatSync to detect broken symlinks (existsSync returns false for broken links)
108
109
  const tsgolintLink = path.join(lintcnDir, '.tsgolint');
109
- if (!fs.existsSync(tsgolintLink)) {
110
- fs.symlinkSync(tsgolintDir, tsgolintLink);
110
+ try {
111
+ fs.lstatSync(tsgolintLink);
112
+ // exists (possibly broken) — remove and recreate
113
+ fs.rmSync(tsgolintLink, { force: true });
114
+ }
115
+ catch {
116
+ // doesn't exist at all
111
117
  }
118
+ fs.symlinkSync(tsgolintDir, tsgolintLink);
112
119
  generateEditorGoFiles(lintcnDir);
113
120
  console.log('Editor support files generated (go.work, go.mod)');
114
121
  }
@@ -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,CAiDlB;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,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"}
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { requireLintcnDir } from "../paths.js";
6
6
  import { discoverRules } from "../discover.js";
7
7
  import { generateBuildWorkspace } from "../codegen.js";
8
- import { ensureTsgolintSource, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
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";
11
11
  async function checkGoInstalled() {
@@ -13,18 +13,19 @@ async function checkGoInstalled() {
13
13
  await execAsync('go', ['version']);
14
14
  }
15
15
  catch {
16
- throw new Error('Go 1.26+ is required to build rules.\n' +
16
+ throw new Error('Go is required to build rules.\n' +
17
17
  'Install from https://go.dev/dl/');
18
18
  }
19
19
  }
20
20
  export async function buildBinary({ rebuild, tsgolintVersion, }) {
21
+ validateVersion(tsgolintVersion);
21
22
  await checkGoInstalled();
22
23
  const lintcnDir = requireLintcnDir();
23
24
  const rules = discoverRules(lintcnDir);
24
25
  if (rules.length === 0) {
25
26
  throw new Error('No rules found in .lintcn/. Run `lintcn add <url>` to add rules.');
26
27
  }
27
- console.log(`Found ${rules.length} custom rule${rules.length === 1 ? '' : 's'} (tsgolint ${tsgolintVersion})`);
28
+ console.log(`Found ${rules.length} custom rule${rules.length === 1 ? '' : 's'} (tsgolint ${tsgolintVersion.slice(0, 8)})`);
28
29
  // ensure tsgolint source
29
30
  const tsgolintDir = await ensureTsgolintSource(tsgolintVersion);
30
31
  // compute content hash
@@ -37,8 +38,8 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
37
38
  console.log('Using cached binary');
38
39
  return getBinaryPath(contentHash);
39
40
  }
40
- // generate build workspace
41
- const buildDir = getBuildDir();
41
+ // generate build workspace (per-hash dir to avoid races between concurrent processes)
42
+ const buildDir = getBuildDir(contentHash);
42
43
  console.log('Generating build workspace...');
43
44
  generateBuildWorkspace({
44
45
  buildDir,
package/dist/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export { addRule } from './commands/add.ts';
4
4
  export { lint, buildBinary } from './commands/lint.ts';
5
5
  export { listRules } from './commands/list.ts';
6
6
  export { removeRule } from './commands/remove.ts';
7
- export { DEFAULT_TSGOLINT_VERSION } from './cache.ts';
7
+ export { DEFAULT_TSGOLINT_VERSION, validateVersion } from './cache.ts';
8
8
  export { findLintcnDir, getLintcnDir, requireLintcnDir } from './paths.ts';
9
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAC1E,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAC3C,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AACrD,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAC1E,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAC3C,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,EAAE,wBAAwB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA"}
package/dist/index.js CHANGED
@@ -3,5 +3,5 @@ export { addRule } from "./commands/add.js";
3
3
  export { lint, buildBinary } from "./commands/lint.js";
4
4
  export { listRules } from "./commands/list.js";
5
5
  export { removeRule } from "./commands/remove.js";
6
- export { DEFAULT_TSGOLINT_VERSION } from "./cache.js";
6
+ export { DEFAULT_TSGOLINT_VERSION, validateVersion } from "./cache.js";
7
7
  export { findLintcnDir, getLintcnDir, requireLintcnDir } from "./paths.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lintcn",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",
@@ -51,11 +51,13 @@
51
51
  "license": "MIT",
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.0.0",
54
+ "@types/tar": "^7.0.87",
54
55
  "typescript": "5.8.2"
55
56
  },
56
57
  "dependencies": {
57
58
  "find-up": "^8.0.0",
58
- "goke": "^6.3.0"
59
+ "goke": "^6.3.0",
60
+ "tar": "^7.5.12"
59
61
  },
60
62
  "scripts": {
61
63
  "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js"
package/src/cache.ts CHANGED
@@ -4,25 +4,41 @@
4
4
  //
5
5
  // Cache layout:
6
6
  // ~/.cache/lintcn/tsgolint/<version>/ — extracted source (read-only)
7
+ // ~/.cache/lintcn/build/<content-hash>/ — per-hash build workspace (no race)
7
8
  // ~/.cache/lintcn/bin/<content-hash> — compiled binaries
8
9
 
9
10
  import fs from 'node:fs'
10
11
  import os from 'node:os'
11
12
  import path from 'node:path'
13
+ import crypto from 'node:crypto'
12
14
  import { pipeline } from 'node:stream/promises'
15
+ import { extract } from 'tar'
13
16
  import { execAsync } from './exec.ts'
14
17
 
15
18
  // Pinned tsgolint fork commit — updated with each lintcn release.
16
- // Uses remorses/tsgolint fork which exposes pkg/runner.Run() and
17
- // moves internal/ packages to pkg/ for clean external imports.
18
- export const DEFAULT_TSGOLINT_VERSION = 'a93604379da2631b70332a65bc47eb5ced689a3b'
19
+ // Uses remorses/tsgolint fork which adds internal/runner.Run().
20
+ // Only 1 commit on top of upstream zero modifications to existing files.
21
+ export const DEFAULT_TSGOLINT_VERSION = 'e945641eabec22993eda3e7c101692e80417e0ea'
19
22
 
20
23
  // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
21
24
  // Patches from tsgolint/patches/ are applied on top during setup.
22
- // This is the upstream commit the tsgolint submodule was forked from.
23
25
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
24
26
  const TYPESCRIPT_GO_COMMIT = '1b7eabe122e1575a0df9c77eccdf4e063c623224'
25
27
 
28
+ // Strict pattern for version strings — prevents path traversal via ../
29
+ const VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/
30
+
31
+ /** Validate version string to prevent path traversal attacks.
32
+ * Only allows alphanumeric chars, dots, underscores, and hyphens. */
33
+ export function validateVersion(version: string): void {
34
+ if (!VERSION_PATTERN.test(version)) {
35
+ throw new Error(
36
+ `Invalid tsgolint version "${version}". ` +
37
+ 'Version must only contain alphanumeric characters, dots, underscores, and hyphens.',
38
+ )
39
+ }
40
+ }
41
+
26
42
  export function getCacheDir(): string {
27
43
  return path.join(os.homedir(), '.cache', 'lintcn')
28
44
  }
@@ -39,28 +55,50 @@ export function getBinaryPath(contentHash: string): string {
39
55
  return path.join(getBinDir(), contentHash)
40
56
  }
41
57
 
42
- export function getBuildDir(): string {
43
- return path.join(getCacheDir(), 'build')
58
+ /** Per-hash build directory to avoid races between concurrent lintcn processes. */
59
+ export function getBuildDir(contentHash: string): string {
60
+ return path.join(getCacheDir(), 'build', contentHash)
44
61
  }
45
62
 
46
63
  /** Download a tarball from URL and extract it to targetDir.
64
+ * Uses the `tar` npm package for cross-platform support (no shell tar needed).
47
65
  * GitHub tarballs have a top-level directory like `repo-ref/`,
48
66
  * so we strip the first path component during extraction. */
49
67
  async function downloadAndExtract(url: string, targetDir: string): Promise<void> {
50
- const response = await fetch(url)
68
+ const controller = new AbortController()
69
+ const timeout = setTimeout(() => {
70
+ controller.abort(new Error(`Download timed out after 120s: ${url}`))
71
+ }, 120_000)
72
+
73
+ let response: Response
74
+ try {
75
+ response = await fetch(url, { signal: controller.signal })
76
+ } finally {
77
+ clearTimeout(timeout)
78
+ }
79
+
51
80
  if (!response.ok || !response.body) {
52
81
  throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`)
53
82
  }
54
83
 
55
- fs.mkdirSync(targetDir, { recursive: true })
56
-
57
- const tmpTarGz = path.join(os.tmpdir(), `lintcn-${Date.now()}.tar.gz`)
58
- const fileStream = fs.createWriteStream(tmpTarGz)
59
- // @ts-ignore ReadableStream vs NodeJS.ReadableStream mismatch
60
- await pipeline(response.body, fileStream)
61
-
62
- await execAsync('tar', ['xzf', tmpTarGz, '--strip-components=1', '-C', targetDir])
63
- fs.rmSync(tmpTarGz, { force: true })
84
+ // download to temp file with random suffix to avoid collisions
85
+ const tmpTarGz = path.join(os.tmpdir(), `lintcn-${crypto.randomBytes(8).toString('hex')}.tar.gz`)
86
+ try {
87
+ const fileStream = fs.createWriteStream(tmpTarGz)
88
+ // @ts-ignore ReadableStream vs NodeJS.ReadableStream mismatch
89
+ await pipeline(response.body, fileStream)
90
+
91
+ // extract with npm tar package (cross-platform, no shell tar needed)
92
+ fs.mkdirSync(targetDir, { recursive: true })
93
+ await extract({
94
+ file: tmpTarGz,
95
+ cwd: targetDir,
96
+ strip: 1,
97
+ })
98
+ } finally {
99
+ // always clean up temp file
100
+ fs.rmSync(tmpTarGz, { force: true })
101
+ }
64
102
  }
65
103
 
66
104
  /** Apply git-format patches using `patch -p1` (no git required).
@@ -79,6 +117,8 @@ async function applyPatches(patchesDir: string, targetDir: string): Promise<numb
79
117
  }
80
118
 
81
119
  export async function ensureTsgolintSource(version: string): Promise<string> {
120
+ validateVersion(version)
121
+
82
122
  const sourceDir = getTsgolintSourceDir(version)
83
123
  const readyMarker = path.join(sourceDir, '.lintcn-ready')
84
124
 
@@ -86,7 +126,12 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
86
126
  return sourceDir
87
127
  }
88
128
 
89
- // clean up any partial previous attempt so we start fresh
129
+ // Use a temp directory for the download, then atomic rename on success.
130
+ // This prevents concurrent processes from seeing partial state, and
131
+ // avoids the "non-empty dir on retry" problem.
132
+ const tmpDir = path.join(getCacheDir(), 'tsgolint', `.tmp-${version}-${crypto.randomBytes(4).toString('hex')}`)
133
+
134
+ // clean up any partial previous attempt
90
135
  if (fs.existsSync(sourceDir)) {
91
136
  fs.rmSync(sourceDir, { recursive: true })
92
137
  }
@@ -95,16 +140,16 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
95
140
  // download tsgolint fork tarball
96
141
  console.log(`Downloading tsgolint@${version.slice(0, 8)}...`)
97
142
  const tsgolintUrl = `https://github.com/remorses/tsgolint/archive/${version}.tar.gz`
98
- await downloadAndExtract(tsgolintUrl, sourceDir)
143
+ await downloadAndExtract(tsgolintUrl, tmpDir)
99
144
 
100
145
  // download typescript-go from microsoft (base commit before patches)
101
- const tsGoDir = path.join(sourceDir, 'typescript-go')
146
+ const tsGoDir = path.join(tmpDir, 'typescript-go')
102
147
  console.log('Downloading typescript-go...')
103
148
  const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`
104
149
  await downloadAndExtract(tsGoUrl, tsGoDir)
105
150
 
106
151
  // apply tsgolint's patches to typescript-go
107
- const patchesDir = path.join(sourceDir, 'patches')
152
+ const patchesDir = path.join(tmpDir, 'patches')
108
153
  if (fs.existsSync(patchesDir)) {
109
154
  const count = await applyPatches(patchesDir, tsGoDir)
110
155
  if (count > 0) {
@@ -113,7 +158,7 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
113
158
  }
114
159
 
115
160
  // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
116
- const collectionsDir = path.join(sourceDir, 'internal', 'collections')
161
+ const collectionsDir = path.join(tmpDir, 'internal', 'collections')
117
162
  const tsGoCollections = path.join(tsGoDir, 'internal', 'collections')
118
163
  if (fs.existsSync(tsGoCollections)) {
119
164
  fs.mkdirSync(collectionsDir, { recursive: true })
@@ -126,12 +171,17 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
126
171
  }
127
172
 
128
173
  // write ready marker
129
- fs.writeFileSync(readyMarker, new Date().toISOString())
174
+ fs.writeFileSync(path.join(tmpDir, '.lintcn-ready'), new Date().toISOString())
175
+
176
+ // atomic rename: move completed dir to final location
177
+ fs.mkdirSync(path.dirname(sourceDir), { recursive: true })
178
+ fs.renameSync(tmpDir, sourceDir)
179
+
130
180
  console.log('tsgolint source ready')
131
181
  } catch (err) {
132
- // clean up partial download so next run starts fresh
133
- if (fs.existsSync(sourceDir)) {
134
- fs.rmSync(sourceDir, { recursive: true })
182
+ // clean up partial temp directory
183
+ if (fs.existsSync(tmpDir)) {
184
+ fs.rmSync(tmpDir, { recursive: true })
135
185
  }
136
186
  throw err
137
187
  }
package/src/codegen.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  // Generate Go workspace files for building a custom tsgolint binary.
2
- // With the fork (remorses/tsgolint) exposing pkg/runner.Run(), codegen is
3
- // minimal: a 10-line main.go template + go.work with shim replaces.
4
- // No regex surgery, no file copying, no fragile string manipulation.
2
+ // Uses internal/runner.Run() from the fork — codegen is just a static
3
+ // main.go template + go.work with shim replaces.
4
+ //
5
+ // Key: module names must be child paths of github.com/typescript-eslint/tsgolint
6
+ // so Go allows importing internal/ packages across the module boundary.
5
7
 
6
8
  import fs from 'node:fs'
7
9
  import path from 'node:path'
8
10
  import type { RuleMetadata } from './discover.ts'
9
11
 
10
12
  // Shim modules that need replace directives in go.work.
11
- // These redirect module paths to local directories inside the tsgolint source.
12
13
  const SHIM_MODULES = [
13
14
  'ast',
14
15
  'bundled',
@@ -26,6 +27,8 @@ const SHIM_MODULES = [
26
27
  'vfs/osvfs',
27
28
  ] as const
28
29
 
30
+ const TSGOLINT_MODULE = 'github.com/typescript-eslint/tsgolint'
31
+
29
32
  function generateReplaceDirectives(tsgolintRelPath: string): string {
30
33
  return SHIM_MODULES.map((mod) => {
31
34
  return `\tgithub.com/microsoft/typescript-go/shim/${mod} => ${tsgolintRelPath}/shim/${mod}`
@@ -47,8 +50,9 @@ ${generateReplaceDirectives('./.tsgolint')}
47
50
  )
48
51
  `
49
52
 
50
- // No child-path hack neededpkg/ is public, any module name works
51
- const goMod = `module lintcn-rules
53
+ // Module name is a child path of tsgolintthis is required so Go allows
54
+ // importing internal/ packages across the module boundary in a workspace.
55
+ const goMod = `module ${TSGOLINT_MODULE}/lintcn-rules
52
56
 
53
57
  go 1.26
54
58
  `
@@ -69,9 +73,7 @@ go.sum
69
73
  }
70
74
  }
71
75
 
72
- /** Generate build workspace for compiling the custom binary.
73
- * With pkg/runner.Run(), the generated main.go is a static template —
74
- * no regex surgery or file copying needed. */
76
+ /** Generate build workspace for compiling the custom binary. */
75
77
  export function generateBuildWorkspace({
76
78
  buildDir,
77
79
  tsgolintDir,
@@ -115,20 +117,19 @@ ${generateReplaceDirectives('./tsgolint')}
115
117
  `
116
118
  fs.writeFileSync(path.join(buildDir, 'go.work'), goWork)
117
119
 
118
- // wrapper/go.modsimple module name, no child-path hack needed
119
- const wrapperGoMod = `module lintcn-wrapper
120
+ // wrapper module child path of tsgolint for internal/ access
121
+ const wrapperGoMod = `module ${TSGOLINT_MODULE}/lintcn-wrapper
120
122
 
121
123
  go 1.26
122
124
  `
123
125
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'go.mod'), wrapperGoMod)
124
126
 
125
- // wrapper/main.go — simple template, no regex or string surgery
127
+ // wrapper/main.go — static template
126
128
  const mainGo = generateMainGo(rules)
127
129
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo)
128
130
  }
129
131
 
130
- /** Generate a minimal main.go that imports user rules and calls runner.Run().
131
- * This is a static template — no copying or patching of tsgolint source. */
132
+ /** Generate main.go that imports user rules and calls internal/runner.Run(). */
132
133
  function generateMainGo(rules: RuleMetadata[]): string {
133
134
  const ruleEntries = rules.map((r) => {
134
135
  return `\t\tlintcn.${r.varName},`
@@ -140,9 +141,9 @@ package main
140
141
  import (
141
142
  \t"os"
142
143
 
143
- \t"github.com/typescript-eslint/tsgolint/pkg/rule"
144
- \t"github.com/typescript-eslint/tsgolint/pkg/runner"
145
- \tlintcn "lintcn-rules"
144
+ \t"${TSGOLINT_MODULE}/internal/rule"
145
+ \t"${TSGOLINT_MODULE}/internal/runner"
146
+ \tlintcn "${TSGOLINT_MODULE}/lintcn-rules"
146
147
  )
147
148
 
148
149
  func main() {
@@ -122,11 +122,17 @@ export async function addRule(url: string): Promise<void> {
122
122
  // ensure .tsgolint source is available and generate editor support files
123
123
  const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION)
124
124
 
125
- // create .tsgolint symlink inside .lintcn for gopls
125
+ // create .tsgolint symlink inside .lintcn for gopls.
126
+ // Use lstatSync to detect broken symlinks (existsSync returns false for broken links)
126
127
  const tsgolintLink = path.join(lintcnDir, '.tsgolint')
127
- if (!fs.existsSync(tsgolintLink)) {
128
- fs.symlinkSync(tsgolintDir, tsgolintLink)
128
+ try {
129
+ fs.lstatSync(tsgolintLink)
130
+ // exists (possibly broken) — remove and recreate
131
+ fs.rmSync(tsgolintLink, { force: true })
132
+ } catch {
133
+ // doesn't exist at all
129
134
  }
135
+ fs.symlinkSync(tsgolintDir, tsgolintLink)
130
136
 
131
137
  generateEditorGoFiles(lintcnDir)
132
138
  console.log('Editor support files generated (go.work, go.mod)')
@@ -6,7 +6,7 @@ import { spawn } from 'node:child_process'
6
6
  import { requireLintcnDir } from '../paths.ts'
7
7
  import { discoverRules } from '../discover.ts'
8
8
  import { generateBuildWorkspace } from '../codegen.ts'
9
- import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
9
+ import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
10
10
  import { computeContentHash } from '../hash.ts'
11
11
  import { execAsync } from '../exec.ts'
12
12
 
@@ -15,7 +15,7 @@ async function checkGoInstalled(): Promise<void> {
15
15
  await execAsync('go', ['version'])
16
16
  } catch {
17
17
  throw new Error(
18
- 'Go 1.26+ is required to build rules.\n' +
18
+ 'Go is required to build rules.\n' +
19
19
  'Install from https://go.dev/dl/',
20
20
  )
21
21
  }
@@ -28,6 +28,7 @@ export async function buildBinary({
28
28
  rebuild: boolean
29
29
  tsgolintVersion: string
30
30
  }): Promise<string> {
31
+ validateVersion(tsgolintVersion)
31
32
  await checkGoInstalled()
32
33
 
33
34
  const lintcnDir = requireLintcnDir()
@@ -37,7 +38,7 @@ export async function buildBinary({
37
38
  throw new Error('No rules found in .lintcn/. Run `lintcn add <url>` to add rules.')
38
39
  }
39
40
 
40
- console.log(`Found ${rules.length} custom rule${rules.length === 1 ? '' : 's'} (tsgolint ${tsgolintVersion})`)
41
+ console.log(`Found ${rules.length} custom rule${rules.length === 1 ? '' : 's'} (tsgolint ${tsgolintVersion.slice(0, 8)})`)
41
42
 
42
43
  // ensure tsgolint source
43
44
  const tsgolintDir = await ensureTsgolintSource(tsgolintVersion)
@@ -54,8 +55,8 @@ export async function buildBinary({
54
55
  return getBinaryPath(contentHash)
55
56
  }
56
57
 
57
- // generate build workspace
58
- const buildDir = getBuildDir()
58
+ // generate build workspace (per-hash dir to avoid races between concurrent processes)
59
+ const buildDir = getBuildDir(contentHash)
59
60
  console.log('Generating build workspace...')
60
61
  generateBuildWorkspace({
61
62
  buildDir,
package/src/index.ts CHANGED
@@ -4,5 +4,5 @@ export { addRule } from './commands/add.ts'
4
4
  export { lint, buildBinary } from './commands/lint.ts'
5
5
  export { listRules } from './commands/list.ts'
6
6
  export { removeRule } from './commands/remove.ts'
7
- export { DEFAULT_TSGOLINT_VERSION } from './cache.ts'
7
+ export { DEFAULT_TSGOLINT_VERSION, validateVersion } from './cache.ts'
8
8
  export { findLintcnDir, getLintcnDir, requireLintcnDir } from './paths.ts'
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Kimaki
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.