lintcn 0.3.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,3 +1,19 @@
1
+ ## 0.5.0
2
+
3
+ 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.
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.
8
+
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
16
+
1
17
  ## 0.3.0
2
18
 
3
19
  1. **Only custom rules run by default** — previously the binary included all 44 built-in tsgolint rules, producing thousands of noisy errors. Now only your `.lintcn/` rules run. True shadcn model: explicitly add each rule you want.
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 = "v0.9.2";
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":"AAmBA,eAAO,MAAM,wBAAwB,WAAW,CAAA;AAOhD,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;AAuCD,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
@@ -1,24 +1,36 @@
1
1
  // Manage cached tsgolint source and compiled binaries.
2
- // Downloads tsgolint + typescript-go as tarballs from GitHub (no git required),
3
- // applies patches with `patch -p1`, and copies internal/collections.
2
+ // Downloads tsgolint fork + typescript-go as tarballs from GitHub,
3
+ // applies tsgolint's patches to typescript-go, and copies collections.
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
- // Pinned tsgolint version — updated with each lintcn release.
14
- // This ensures reproducible builds: every user on the same lintcn version
15
- // compiles rules against the same tsgolint API. Changing this is a conscious
16
- // decision tsgolint API changes can break user rules.
17
- export const DEFAULT_TSGOLINT_VERSION = 'v0.9.2';
18
- // Pinned typescript-go commit that tsgolint v0.9.2 depends on.
19
- // Found via `git ls-tree HEAD typescript-go` in the tsgolint repo.
16
+ // Pinned tsgolint fork commit — updated with each lintcn release.
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';
20
+ // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
21
+ // Patches from tsgolint/patches/ are applied on top during setup.
20
22
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
21
- const TYPESCRIPT_GO_COMMIT = '2437fa43e85103d2a18e8e41e1a2a994d0708ccf';
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,25 +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
- // pipe through gunzip, then extract with tar (strip top-level directory)
47
- const tmpTarGz = path.join(os.tmpdir(), `lintcn-${Date.now()}.tar.gz`);
48
- const fileStream = fs.createWriteStream(tmpTarGz);
49
- // @ts-ignore ReadableStream vs NodeJS.ReadableStream mismatch
50
- await pipeline(response.body, fileStream);
51
- await execAsync('tar', ['xzf', tmpTarGz, '--strip-components=1', '-C', targetDir]);
52
- 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
+ }
53
87
  }
54
88
  /** Apply git-format patches using `patch -p1` (no git required).
55
89
  * Patches are standard unified diff format, `patch` ignores the git metadata. */
@@ -59,33 +93,37 @@ async function applyPatches(patchesDir, targetDir) {
59
93
  .sort();
60
94
  for (const patchFile of patches) {
61
95
  const patchPath = path.join(patchesDir, patchFile);
62
- // --batch silences interactive prompts, -f forces application
63
96
  await execAsync('patch', ['-p1', '--batch', '-i', patchPath], { cwd: targetDir });
64
97
  }
65
98
  return patches.length;
66
99
  }
67
100
  export async function ensureTsgolintSource(version) {
101
+ validateVersion(version);
68
102
  const sourceDir = getTsgolintSourceDir(version);
69
103
  const readyMarker = path.join(sourceDir, '.lintcn-ready');
70
104
  if (fs.existsSync(readyMarker)) {
71
105
  return sourceDir;
72
106
  }
73
- // 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
74
112
  if (fs.existsSync(sourceDir)) {
75
113
  fs.rmSync(sourceDir, { recursive: true });
76
114
  }
77
115
  try {
78
- // download tsgolint source tarball
79
- console.log(`Downloading tsgolint@${version}...`);
80
- const tsgolintUrl = `https://github.com/oxc-project/tsgolint/archive/refs/tags/${version}.tar.gz`;
81
- await downloadAndExtract(tsgolintUrl, sourceDir);
82
- // download typescript-go source tarball into tsgolint/typescript-go/
83
- const tsGoDir = path.join(sourceDir, 'typescript-go');
116
+ // download tsgolint fork tarball
117
+ console.log(`Downloading tsgolint@${version.slice(0, 8)}...`);
118
+ const tsgolintUrl = `https://github.com/remorses/tsgolint/archive/${version}.tar.gz`;
119
+ await downloadAndExtract(tsgolintUrl, tmpDir);
120
+ // download typescript-go from microsoft (base commit before patches)
121
+ const tsGoDir = path.join(tmpDir, 'typescript-go');
84
122
  console.log('Downloading typescript-go...');
85
123
  const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`;
86
124
  await downloadAndExtract(tsGoUrl, tsGoDir);
87
- // apply patches to typescript-go
88
- const patchesDir = path.join(sourceDir, 'patches');
125
+ // apply tsgolint's patches to typescript-go
126
+ const patchesDir = path.join(tmpDir, 'patches');
89
127
  if (fs.existsSync(patchesDir)) {
90
128
  const count = await applyPatches(patchesDir, tsGoDir);
91
129
  if (count > 0) {
@@ -93,7 +131,7 @@ export async function ensureTsgolintSource(version) {
93
131
  }
94
132
  }
95
133
  // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
96
- const collectionsDir = path.join(sourceDir, 'internal', 'collections');
134
+ const collectionsDir = path.join(tmpDir, 'internal', 'collections');
97
135
  const tsGoCollections = path.join(tsGoDir, 'internal', 'collections');
98
136
  if (fs.existsSync(tsGoCollections)) {
99
137
  fs.mkdirSync(collectionsDir, { recursive: true });
@@ -105,13 +143,16 @@ export async function ensureTsgolintSource(version) {
105
143
  }
106
144
  }
107
145
  // write ready marker
108
- 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);
109
150
  console.log('tsgolint source ready');
110
151
  }
111
152
  catch (err) {
112
- // clean up partial download so next run starts fresh
113
- if (fs.existsSync(sourceDir)) {
114
- fs.rmSync(sourceDir, { recursive: true });
153
+ // clean up partial temp directory
154
+ if (fs.existsSync(tmpDir)) {
155
+ fs.rmSync(tmpDir, { recursive: true });
115
156
  }
116
157
  throw err;
117
158
  }
package/dist/codegen.d.ts CHANGED
@@ -1,24 +1,11 @@
1
1
  import type { RuleMetadata } from './discover.ts';
2
- /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support.
3
- *
4
- * Key learnings from testing:
5
- * - Module name MUST be a child path of github.com/typescript-eslint/tsgolint
6
- * so Go allows importing internal/ packages across the module boundary.
7
- * - go.work must `use` both .tsgolint AND .tsgolint/typescript-go since
8
- * tsgolint's own go.work (which does this) is ignored by the outer workspace.
9
- * - go.mod should be minimal (no requires) — the workspace resolves everything. */
2
+ /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support. */
10
3
  export declare function generateEditorGoFiles(lintcnDir: string): void;
11
- /** Generate build workspace in cache dir for compiling the custom binary.
12
- * Instead of hardcoding the built-in rule list, we copy tsgolint's actual
13
- * main.go and inject custom rule imports + entries. This way the generated
14
- * code always matches the pinned tsgolint version. */
4
+ /** Generate build workspace for compiling the custom binary. */
15
5
  export declare function generateBuildWorkspace({ buildDir, tsgolintDir, lintcnDir, rules, }: {
16
6
  buildDir: string;
17
7
  tsgolintDir: string;
18
8
  lintcnDir: string;
19
9
  rules: RuleMetadata[];
20
10
  }): void;
21
- /** Copy all supporting .go files from cmd/tsgolint/ into the wrapper dir.
22
- * main.go is generated separately with custom rules injected. */
23
- export declare function copyTsgolintCmdFiles(tsgolintDir: string, wrapperDir: string): void;
24
11
  //# sourceMappingURL=codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../src/codegen.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AA2BjD;;;;;;;oFAOoF;AACpF,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAiC7D;AAED;;;uDAGuD;AACvD,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,CAiDP;AA8DD;kEACkE;AAClE,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAQlF"}
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,14 +1,12 @@
1
1
  // Generate Go workspace files for building a custom tsgolint binary.
2
- // Creates:
3
- // .lintcn/go.work workspace for gopls (editor support)
4
- // .lintcn/go.mod — module declaration
5
- // build/go.work — build workspace in cache dir
6
- // build/wrapper/go.mod — wrapper module
7
- // build/wrapper/main.go — tsgolint main.go with custom rules appended
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.
8
7
  import fs from 'node:fs';
9
8
  import path from 'node:path';
10
- // All replace directives needed from tsgolint's go.mod.
11
- // These redirect shim module paths to local directories inside the tsgolint source.
9
+ // Shim modules that need replace directives in go.work.
12
10
  const SHIM_MODULES = [
13
11
  'ast',
14
12
  'bundled',
@@ -25,19 +23,13 @@ const SHIM_MODULES = [
25
23
  'vfs/cachedvfs',
26
24
  'vfs/osvfs',
27
25
  ];
26
+ const TSGOLINT_MODULE = 'github.com/typescript-eslint/tsgolint';
28
27
  function generateReplaceDirectives(tsgolintRelPath) {
29
28
  return SHIM_MODULES.map((mod) => {
30
29
  return `\tgithub.com/microsoft/typescript-go/shim/${mod} => ${tsgolintRelPath}/shim/${mod}`;
31
30
  }).join('\n');
32
31
  }
33
- /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support.
34
- *
35
- * Key learnings from testing:
36
- * - Module name MUST be a child path of github.com/typescript-eslint/tsgolint
37
- * so Go allows importing internal/ packages across the module boundary.
38
- * - go.work must `use` both .tsgolint AND .tsgolint/typescript-go since
39
- * tsgolint's own go.work (which does this) is ignored by the outer workspace.
40
- * - go.mod should be minimal (no requires) — the workspace resolves everything. */
32
+ /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support. */
41
33
  export function generateEditorGoFiles(lintcnDir) {
42
34
  const goWork = `go 1.26
43
35
 
@@ -51,7 +43,9 @@ replace (
51
43
  ${generateReplaceDirectives('./.tsgolint')}
52
44
  )
53
45
  `;
54
- const goMod = `module github.com/typescript-eslint/tsgolint/lintcn-rules
46
+ // Module name is a child path of tsgolint — this is required so Go allows
47
+ // importing internal/ packages across the module boundary in a workspace.
48
+ const goMod = `module ${TSGOLINT_MODULE}/lintcn-rules
55
49
 
56
50
  go 1.26
57
51
  `;
@@ -68,10 +62,7 @@ go.sum
68
62
  fs.writeFileSync(gitignorePath, gitignore);
69
63
  }
70
64
  }
71
- /** Generate build workspace in cache dir for compiling the custom binary.
72
- * Instead of hardcoding the built-in rule list, we copy tsgolint's actual
73
- * main.go and inject custom rule imports + entries. This way the generated
74
- * code always matches the pinned tsgolint version. */
65
+ /** Generate build workspace for compiling the custom binary. */
75
66
  export function generateBuildWorkspace({ buildDir, tsgolintDir, lintcnDir, rules, }) {
76
67
  fs.mkdirSync(path.join(buildDir, 'wrapper'), { recursive: true });
77
68
  // symlink tsgolint source
@@ -86,7 +77,7 @@ export function generateBuildWorkspace({ buildDir, tsgolintDir, lintcnDir, rules
86
77
  fs.rmSync(rulesLink, { recursive: true });
87
78
  }
88
79
  fs.symlinkSync(path.resolve(lintcnDir), rulesLink);
89
- // go.work — must include typescript-go submodule and use child module paths
80
+ // go.work
90
81
  const goWork = `go 1.26
91
82
 
92
83
  use (
@@ -101,73 +92,37 @@ ${generateReplaceDirectives('./tsgolint')}
101
92
  )
102
93
  `;
103
94
  fs.writeFileSync(path.join(buildDir, 'go.work'), goWork);
104
- // wrapper/go.modmust be child path of tsgolint for internal/ access.
105
- // Minimal: no require block. The workspace resolves all dependencies.
106
- // Adding explicit requires with v0.0.0 triggers Go proxy lookups that fail.
107
- const wrapperGoMod = `module github.com/typescript-eslint/tsgolint/lintcn-wrapper
95
+ // wrapper module — child path of tsgolint for internal/ access
96
+ const wrapperGoMod = `module ${TSGOLINT_MODULE}/lintcn-wrapper
108
97
 
109
98
  go 1.26
110
99
  `;
111
100
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'go.mod'), wrapperGoMod);
112
- // copy all supporting .go files from cmd/tsgolint/ (headless, payload, etc.)
113
- const wrapperDir = path.join(buildDir, 'wrapper');
114
- copyTsgolintCmdFiles(tsgolintDir, wrapperDir);
115
- // wrapper/main.go — copy from tsgolint and inject custom rules
116
- const mainGo = generateMainGoFromSource(tsgolintDir, rules);
117
- fs.writeFileSync(path.join(wrapperDir, 'main.go'), mainGo);
101
+ // wrapper/main.go static template
102
+ const mainGo = generateMainGo(rules);
103
+ fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo);
118
104
  }
119
- /** Copy tsgolint's main.go and transform it to only include custom rules.
120
- * Two targeted string operations on the copied source:
121
- * 1. Remove all /internal/rules/ import lines (built-in rule packages)
122
- * 2. Replace allRules body with only custom lintcn.* entries
123
- * Everything else (printDiagnostic, runMain, headless) stays untouched. */
124
- function generateMainGoFromSource(tsgolintDir, customRules) {
125
- const mainGoPath = path.join(tsgolintDir, 'cmd', 'tsgolint', 'main.go');
126
- const original = fs.readFileSync(mainGoPath, 'utf-8');
127
- // 1. Remove built-in rule import lines, add lintcn import
128
- const lines = original.split('\n');
129
- const filtered = lines.filter((line) => {
130
- return !line.includes('/internal/rules/');
131
- });
132
- // Insert lintcn import before the first shim import (microsoft/typescript-go)
133
- const lintcnImport = `\tlintcn "github.com/typescript-eslint/tsgolint/lintcn-rules"`;
134
- let shimImportIndex = -1;
135
- for (let i = 0; i < filtered.length; i++) {
136
- if (filtered[i].includes('microsoft/typescript-go/shim')) {
137
- shimImportIndex = i;
138
- break;
139
- }
140
- }
141
- if (shimImportIndex === -1) {
142
- throw new Error('Failed to find shim import in tsgolint main.go. The source layout may have changed.');
143
- }
144
- if (customRules.length > 0) {
145
- filtered.splice(shimImportIndex, 0, lintcnImport, '');
146
- }
147
- let mainGo = filtered.join('\n');
148
- // 2. Replace allRules body with only custom entries
149
- const customEntries = customRules.map((r) => {
150
- return `\tlintcn.${r.varName},`;
105
+ /** Generate main.go that imports user rules and calls internal/runner.Run(). */
106
+ function generateMainGo(rules) {
107
+ const ruleEntries = rules.map((r) => {
108
+ return `\t\tlintcn.${r.varName},`;
151
109
  }).join('\n');
152
- const allRulesPattern = /var allRules = \[]rule\.Rule\{[^}]*\}/s;
153
- if (!allRulesPattern.test(mainGo)) {
154
- throw new Error('Failed to find allRules slice in tsgolint main.go. The source layout may have changed.');
155
- }
156
- mainGo = mainGo.replace(allRulesPattern, `var allRules = []rule.Rule{\n${customEntries}\n}`);
157
- // assertion: verify custom rules are present
158
- if (customRules.length > 0 && !mainGo.includes(`lintcn.${customRules[0].varName}`)) {
159
- throw new Error('Custom rule injection verification failed.');
160
- }
161
- return mainGo;
110
+ return `// Code generated by lintcn. DO NOT EDIT.
111
+ package main
112
+
113
+ import (
114
+ \t"os"
115
+
116
+ \t"${TSGOLINT_MODULE}/internal/rule"
117
+ \t"${TSGOLINT_MODULE}/internal/runner"
118
+ \tlintcn "${TSGOLINT_MODULE}/lintcn-rules"
119
+ )
120
+
121
+ func main() {
122
+ \trules := []rule.Rule{
123
+ ${ruleEntries}
124
+ \t}
125
+ \tos.Exit(runner.Run(rules, os.Args[1:]))
162
126
  }
163
- /** Copy all supporting .go files from cmd/tsgolint/ into the wrapper dir.
164
- * main.go is generated separately with custom rules injected. */
165
- export function copyTsgolintCmdFiles(tsgolintDir, wrapperDir) {
166
- const cmdDir = path.join(tsgolintDir, 'cmd', 'tsgolint');
167
- const files = fs.readdirSync(cmdDir).filter((f) => {
168
- return f.endsWith('.go') && f !== 'main.go' && !f.endsWith('_test.go');
169
- });
170
- for (const file of files) {
171
- fs.copyFileSync(path.join(cmdDir, file), path.join(wrapperDir, file));
172
- }
127
+ `;
173
128
  }
@@ -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.3.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
@@ -1,28 +1,43 @@
1
1
  // Manage cached tsgolint source and compiled binaries.
2
- // Downloads tsgolint + typescript-go as tarballs from GitHub (no git required),
3
- // applies patches with `patch -p1`, and copies internal/collections.
2
+ // Downloads tsgolint fork + typescript-go as tarballs from GitHub,
3
+ // applies tsgolint's patches to typescript-go, and copies collections.
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'
13
- import { createGunzip } from 'node:zlib'
15
+ import { extract } from 'tar'
14
16
  import { execAsync } from './exec.ts'
15
17
 
16
- // Pinned tsgolint version — updated with each lintcn release.
17
- // This ensures reproducible builds: every user on the same lintcn version
18
- // compiles rules against the same tsgolint API. Changing this is a conscious
19
- // decision tsgolint API changes can break user rules.
20
- export const DEFAULT_TSGOLINT_VERSION = 'v0.9.2'
18
+ // Pinned tsgolint fork commit — updated with each lintcn release.
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'
21
22
 
22
- // Pinned typescript-go commit that tsgolint v0.9.2 depends on.
23
- // Found via `git ls-tree HEAD typescript-go` in the tsgolint repo.
23
+ // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
24
+ // Patches from tsgolint/patches/ are applied on top during setup.
24
25
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
25
- const TYPESCRIPT_GO_COMMIT = '2437fa43e85103d2a18e8e41e1a2a994d0708ccf'
26
+ const TYPESCRIPT_GO_COMMIT = '1b7eabe122e1575a0df9c77eccdf4e063c623224'
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
+ }
26
41
 
27
42
  export function getCacheDir(): string {
28
43
  return path.join(os.homedir(), '.cache', 'lintcn')
@@ -40,29 +55,50 @@ export function getBinaryPath(contentHash: string): string {
40
55
  return path.join(getBinDir(), contentHash)
41
56
  }
42
57
 
43
- export function getBuildDir(): string {
44
- 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)
45
61
  }
46
62
 
47
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).
48
65
  * GitHub tarballs have a top-level directory like `repo-ref/`,
49
66
  * so we strip the first path component during extraction. */
50
67
  async function downloadAndExtract(url: string, targetDir: string): Promise<void> {
51
- 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
+
52
80
  if (!response.ok || !response.body) {
53
81
  throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`)
54
82
  }
55
83
 
56
- fs.mkdirSync(targetDir, { recursive: true })
57
-
58
- // pipe through gunzip, then extract with tar (strip top-level directory)
59
- const tmpTarGz = path.join(os.tmpdir(), `lintcn-${Date.now()}.tar.gz`)
60
- const fileStream = fs.createWriteStream(tmpTarGz)
61
- // @ts-ignore ReadableStream vs NodeJS.ReadableStream mismatch
62
- await pipeline(response.body, fileStream)
63
-
64
- await execAsync('tar', ['xzf', tmpTarGz, '--strip-components=1', '-C', targetDir])
65
- 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
+ }
66
102
  }
67
103
 
68
104
  /** Apply git-format patches using `patch -p1` (no git required).
@@ -74,7 +110,6 @@ async function applyPatches(patchesDir: string, targetDir: string): Promise<numb
74
110
 
75
111
  for (const patchFile of patches) {
76
112
  const patchPath = path.join(patchesDir, patchFile)
77
- // --batch silences interactive prompts, -f forces application
78
113
  await execAsync('patch', ['-p1', '--batch', '-i', patchPath], { cwd: targetDir })
79
114
  }
80
115
 
@@ -82,6 +117,8 @@ async function applyPatches(patchesDir: string, targetDir: string): Promise<numb
82
117
  }
83
118
 
84
119
  export async function ensureTsgolintSource(version: string): Promise<string> {
120
+ validateVersion(version)
121
+
85
122
  const sourceDir = getTsgolintSourceDir(version)
86
123
  const readyMarker = path.join(sourceDir, '.lintcn-ready')
87
124
 
@@ -89,25 +126,30 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
89
126
  return sourceDir
90
127
  }
91
128
 
92
- // 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
93
135
  if (fs.existsSync(sourceDir)) {
94
136
  fs.rmSync(sourceDir, { recursive: true })
95
137
  }
96
138
 
97
139
  try {
98
- // download tsgolint source tarball
99
- console.log(`Downloading tsgolint@${version}...`)
100
- const tsgolintUrl = `https://github.com/oxc-project/tsgolint/archive/refs/tags/${version}.tar.gz`
101
- await downloadAndExtract(tsgolintUrl, sourceDir)
140
+ // download tsgolint fork tarball
141
+ console.log(`Downloading tsgolint@${version.slice(0, 8)}...`)
142
+ const tsgolintUrl = `https://github.com/remorses/tsgolint/archive/${version}.tar.gz`
143
+ await downloadAndExtract(tsgolintUrl, tmpDir)
102
144
 
103
- // download typescript-go source tarball into tsgolint/typescript-go/
104
- const tsGoDir = path.join(sourceDir, 'typescript-go')
145
+ // download typescript-go from microsoft (base commit before patches)
146
+ const tsGoDir = path.join(tmpDir, 'typescript-go')
105
147
  console.log('Downloading typescript-go...')
106
148
  const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`
107
149
  await downloadAndExtract(tsGoUrl, tsGoDir)
108
150
 
109
- // apply patches to typescript-go
110
- const patchesDir = path.join(sourceDir, 'patches')
151
+ // apply tsgolint's patches to typescript-go
152
+ const patchesDir = path.join(tmpDir, 'patches')
111
153
  if (fs.existsSync(patchesDir)) {
112
154
  const count = await applyPatches(patchesDir, tsGoDir)
113
155
  if (count > 0) {
@@ -116,7 +158,7 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
116
158
  }
117
159
 
118
160
  // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
119
- const collectionsDir = path.join(sourceDir, 'internal', 'collections')
161
+ const collectionsDir = path.join(tmpDir, 'internal', 'collections')
120
162
  const tsGoCollections = path.join(tsGoDir, 'internal', 'collections')
121
163
  if (fs.existsSync(tsGoCollections)) {
122
164
  fs.mkdirSync(collectionsDir, { recursive: true })
@@ -129,12 +171,17 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
129
171
  }
130
172
 
131
173
  // write ready marker
132
- 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
+
133
180
  console.log('tsgolint source ready')
134
181
  } catch (err) {
135
- // clean up partial download so next run starts fresh
136
- if (fs.existsSync(sourceDir)) {
137
- fs.rmSync(sourceDir, { recursive: true })
182
+ // clean up partial temp directory
183
+ if (fs.existsSync(tmpDir)) {
184
+ fs.rmSync(tmpDir, { recursive: true })
138
185
  }
139
186
  throw err
140
187
  }
package/src/codegen.ts CHANGED
@@ -1,17 +1,15 @@
1
1
  // Generate Go workspace files for building a custom tsgolint binary.
2
- // Creates:
3
- // .lintcn/go.work workspace for gopls (editor support)
4
- // .lintcn/go.mod — module declaration
5
- // build/go.work — build workspace in cache dir
6
- // build/wrapper/go.mod — wrapper module
7
- // build/wrapper/main.go — tsgolint main.go with custom rules appended
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.
8
7
 
9
8
  import fs from 'node:fs'
10
9
  import path from 'node:path'
11
10
  import type { RuleMetadata } from './discover.ts'
12
11
 
13
- // All replace directives needed from tsgolint's go.mod.
14
- // These redirect shim module paths to local directories inside the tsgolint source.
12
+ // Shim modules that need replace directives in go.work.
15
13
  const SHIM_MODULES = [
16
14
  'ast',
17
15
  'bundled',
@@ -29,20 +27,15 @@ const SHIM_MODULES = [
29
27
  'vfs/osvfs',
30
28
  ] as const
31
29
 
30
+ const TSGOLINT_MODULE = 'github.com/typescript-eslint/tsgolint'
31
+
32
32
  function generateReplaceDirectives(tsgolintRelPath: string): string {
33
33
  return SHIM_MODULES.map((mod) => {
34
34
  return `\tgithub.com/microsoft/typescript-go/shim/${mod} => ${tsgolintRelPath}/shim/${mod}`
35
35
  }).join('\n')
36
36
  }
37
37
 
38
- /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support.
39
- *
40
- * Key learnings from testing:
41
- * - Module name MUST be a child path of github.com/typescript-eslint/tsgolint
42
- * so Go allows importing internal/ packages across the module boundary.
43
- * - go.work must `use` both .tsgolint AND .tsgolint/typescript-go since
44
- * tsgolint's own go.work (which does this) is ignored by the outer workspace.
45
- * - go.mod should be minimal (no requires) — the workspace resolves everything. */
38
+ /** Generate .lintcn/go.work and .lintcn/go.mod for editor/gopls support. */
46
39
  export function generateEditorGoFiles(lintcnDir: string): void {
47
40
  const goWork = `go 1.26
48
41
 
@@ -57,7 +50,9 @@ ${generateReplaceDirectives('./.tsgolint')}
57
50
  )
58
51
  `
59
52
 
60
- const goMod = `module github.com/typescript-eslint/tsgolint/lintcn-rules
53
+ // Module name is a child path of tsgolint — this is required so Go allows
54
+ // importing internal/ packages across the module boundary in a workspace.
55
+ const goMod = `module ${TSGOLINT_MODULE}/lintcn-rules
61
56
 
62
57
  go 1.26
63
58
  `
@@ -78,10 +73,7 @@ go.sum
78
73
  }
79
74
  }
80
75
 
81
- /** Generate build workspace in cache dir for compiling the custom binary.
82
- * Instead of hardcoding the built-in rule list, we copy tsgolint's actual
83
- * main.go and inject custom rule imports + entries. This way the generated
84
- * code always matches the pinned tsgolint version. */
76
+ /** Generate build workspace for compiling the custom binary. */
85
77
  export function generateBuildWorkspace({
86
78
  buildDir,
87
79
  tsgolintDir,
@@ -109,7 +101,7 @@ export function generateBuildWorkspace({
109
101
  }
110
102
  fs.symlinkSync(path.resolve(lintcnDir), rulesLink)
111
103
 
112
- // go.work — must include typescript-go submodule and use child module paths
104
+ // go.work
113
105
  const goWork = `go 1.26
114
106
 
115
107
  use (
@@ -125,92 +117,40 @@ ${generateReplaceDirectives('./tsgolint')}
125
117
  `
126
118
  fs.writeFileSync(path.join(buildDir, 'go.work'), goWork)
127
119
 
128
- // wrapper/go.modmust be child path of tsgolint for internal/ access.
129
- // Minimal: no require block. The workspace resolves all dependencies.
130
- // Adding explicit requires with v0.0.0 triggers Go proxy lookups that fail.
131
- const wrapperGoMod = `module github.com/typescript-eslint/tsgolint/lintcn-wrapper
120
+ // wrapper module — child path of tsgolint for internal/ access
121
+ const wrapperGoMod = `module ${TSGOLINT_MODULE}/lintcn-wrapper
132
122
 
133
123
  go 1.26
134
124
  `
135
125
  fs.writeFileSync(path.join(buildDir, 'wrapper', 'go.mod'), wrapperGoMod)
136
126
 
137
- // copy all supporting .go files from cmd/tsgolint/ (headless, payload, etc.)
138
- const wrapperDir = path.join(buildDir, 'wrapper')
139
- copyTsgolintCmdFiles(tsgolintDir, wrapperDir)
140
-
141
- // wrapper/main.go — copy from tsgolint and inject custom rules
142
- const mainGo = generateMainGoFromSource(tsgolintDir, rules)
143
- fs.writeFileSync(path.join(wrapperDir, 'main.go'), mainGo)
127
+ // wrapper/main.go static template
128
+ const mainGo = generateMainGo(rules)
129
+ fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo)
144
130
  }
145
131
 
146
- /** Copy tsgolint's main.go and transform it to only include custom rules.
147
- * Two targeted string operations on the copied source:
148
- * 1. Remove all /internal/rules/ import lines (built-in rule packages)
149
- * 2. Replace allRules body with only custom lintcn.* entries
150
- * Everything else (printDiagnostic, runMain, headless) stays untouched. */
151
- function generateMainGoFromSource(tsgolintDir: string, customRules: RuleMetadata[]): string {
152
- const mainGoPath = path.join(tsgolintDir, 'cmd', 'tsgolint', 'main.go')
153
- const original = fs.readFileSync(mainGoPath, 'utf-8')
154
-
155
- // 1. Remove built-in rule import lines, add lintcn import
156
- const lines = original.split('\n')
157
- const filtered = lines.filter((line) => {
158
- return !line.includes('/internal/rules/')
159
- })
160
-
161
- // Insert lintcn import before the first shim import (microsoft/typescript-go)
162
- const lintcnImport = `\tlintcn "github.com/typescript-eslint/tsgolint/lintcn-rules"`
163
- let shimImportIndex = -1
164
- for (let i = 0; i < filtered.length; i++) {
165
- if (filtered[i].includes('microsoft/typescript-go/shim')) {
166
- shimImportIndex = i
167
- break
168
- }
169
- }
170
- if (shimImportIndex === -1) {
171
- throw new Error(
172
- 'Failed to find shim import in tsgolint main.go. The source layout may have changed.',
173
- )
174
- }
175
- if (customRules.length > 0) {
176
- filtered.splice(shimImportIndex, 0, lintcnImport, '')
177
- }
178
-
179
- let mainGo = filtered.join('\n')
180
-
181
- // 2. Replace allRules body with only custom entries
182
- const customEntries = customRules.map((r) => {
183
- return `\tlintcn.${r.varName},`
132
+ /** Generate main.go that imports user rules and calls internal/runner.Run(). */
133
+ function generateMainGo(rules: RuleMetadata[]): string {
134
+ const ruleEntries = rules.map((r) => {
135
+ return `\t\tlintcn.${r.varName},`
184
136
  }).join('\n')
185
137
 
186
- const allRulesPattern = /var allRules = \[]rule\.Rule\{[^}]*\}/s
187
- if (!allRulesPattern.test(mainGo)) {
188
- throw new Error(
189
- 'Failed to find allRules slice in tsgolint main.go. The source layout may have changed.',
190
- )
191
- }
138
+ return `// Code generated by lintcn. DO NOT EDIT.
139
+ package main
192
140
 
193
- mainGo = mainGo.replace(
194
- allRulesPattern,
195
- `var allRules = []rule.Rule{\n${customEntries}\n}`,
196
- )
141
+ import (
142
+ \t"os"
197
143
 
198
- // assertion: verify custom rules are present
199
- if (customRules.length > 0 && !mainGo.includes(`lintcn.${customRules[0].varName}`)) {
200
- throw new Error('Custom rule injection verification failed.')
201
- }
144
+ \t"${TSGOLINT_MODULE}/internal/rule"
145
+ \t"${TSGOLINT_MODULE}/internal/runner"
146
+ \tlintcn "${TSGOLINT_MODULE}/lintcn-rules"
147
+ )
202
148
 
203
- return mainGo
149
+ func main() {
150
+ \trules := []rule.Rule{
151
+ ${ruleEntries}
152
+ \t}
153
+ \tos.Exit(runner.Run(rules, os.Args[1:]))
204
154
  }
205
-
206
- /** Copy all supporting .go files from cmd/tsgolint/ into the wrapper dir.
207
- * main.go is generated separately with custom rules injected. */
208
- export function copyTsgolintCmdFiles(tsgolintDir: string, wrapperDir: string): void {
209
- const cmdDir = path.join(tsgolintDir, 'cmd', 'tsgolint')
210
- const files = fs.readdirSync(cmdDir).filter((f) => {
211
- return f.endsWith('.go') && f !== 'main.go' && !f.endsWith('_test.go')
212
- })
213
- for (const file of files) {
214
- fs.copyFileSync(path.join(cmdDir, file), path.join(wrapperDir, file))
215
- }
155
+ `
216
156
  }
@@ -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.