lintcn 0.9.0 → 0.10.1

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,35 @@
1
+ ## 0.10.1
2
+
3
+ 1. **Fixed concurrent cache builds** — multiple lintcn processes no longer corrupt each other's build cache. Cache acquisition now uses proper directory-based locking with stale lock detection and retry logic. Thanks @tanishqkancharla for #1!
4
+ 2. **Fixed prefer-object-params crash on computed property names** — the rule no longer panics when encountering computed names like `[Symbol.iterator]` or `[EventEmitter.captureRejectionSymbol]`. Diagnostics fall back to a generic message for computed names while keeping specific messages for named declarations.
5
+ 3. **Fixed no-unused-top-level-function test flakiness** — test cases now use isolated virtual tsconfig files with unique filenames, preventing cross-test interference when running in parallel.
6
+ 4. **Updated tsgolint fork** — bumped to latest `remorses/tsgolint` commit with updated `typescript-go` base. Also fixed `no-redundant-contextual-parameter-type` to use a direct Kind check instead of `ast.IsParameter()` to avoid potential panics on unexpected node kinds.
7
+
8
+ ## 0.10.0
9
+
10
+ 1. **New rule: `prefer-is-truthy` (error)** — errors on inline nullable type guards passed directly to `filter`. When the callback parameter is already `T | null | undefined`, prefer a reusable `isTruthy` helper over an ad hoc predicate at the call site:
11
+
12
+ ```ts
13
+ // flagged — ad hoc inline type guard
14
+ const active = pokemon.filter(
15
+ (value): value is Pokemon => value !== null,
16
+ )
17
+
18
+ // preferred — reusable helper
19
+ const active = pokemon.filter(isTruthy)
20
+ ```
21
+
22
+ Standalone reusable guards, filter callbacks without a type predicate, and other callback sites are exempt.
23
+
24
+ 2. **`no-type-assertion` no longer warns when the source is `any` or `unknown`** — assertions from `any` or `unknown` are now silently allowed since they're the standard pattern for untyped or narrowing contexts. Warnings are still emitted when a typed value is cast to `any`, `unknown`, or an unrelated concrete type:
25
+
26
+ ```ts
27
+ const x = response.data as User // allowed when response.data is `any`
28
+ const y = value as unknown // still warned — escaping a typed value
29
+ ```
30
+
31
+ 3. **`no-unsafe-unknown` aligns with `no-type-assertion`** — casts from `unknown` into a concrete target type no longer warn (that's a normal narrowing pattern). Warnings are kept for explicit `unknown` targets and assertions that embed `unknown` in a larger type like `Promise<unknown>`.
32
+
1
33
  ## 0.9.0
2
34
 
3
35
  1. **New rule: `no-redundant-exported-return-type` (warn)** — warns when exported APIs keep spelling `ReturnType<typeof ...>` even though the function already returns a public named type. This keeps public types direct and easier to read:
package/dist/cache.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const DEFAULT_TSGOLINT_VERSION = "427f872946bb413f0e21cff585b6139c986c89d3";
1
+ export declare const DEFAULT_TSGOLINT_VERSION = "c031d7264983dba00bae76eb03532fe3884e5667";
2
2
  /** Validate version string to prevent path traversal attacks.
3
3
  * Only allows alphanumeric chars, dots, underscores, and hyphens. */
4
4
  export declare function validateVersion(version: string): void;
@@ -8,6 +8,11 @@ export declare function getBinDir(): string;
8
8
  export declare function getBinaryPath(contentHash: string): string;
9
9
  /** Per-hash build directory to avoid races between concurrent lintcn processes. */
10
10
  export declare function getBuildDir(contentHash: string): string;
11
+ /** Per-hash lock directory used while generating and compiling a build workspace. */
12
+ export declare function getBuildLockDir(contentHash: string): string;
13
+ /** Per-version lock directory used while populating cached tsgolint source. */
14
+ export declare function getTsgolintSourceLockDir(version: string): string;
15
+ export declare function acquireCacheLock(lockDir: string, description: string): Promise<string>;
11
16
  export declare function ensureTsgolintSource(version: string): Promise<string>;
12
17
  export declare function cachedBinaryExists(contentHash: string): boolean;
13
18
  //# sourceMappingURL=cache.d.ts.map
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,wBAAwB,6CAA6C,CAAA;AAYlF;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;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,+EAA+E;AAC/E,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEhE;AAQD,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqB5F;AA0DD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAiF3E;AAED,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAQ/D"}
package/dist/cache.js CHANGED
@@ -16,13 +16,15 @@ import { execAsync } from "./exec.js";
16
16
  // Pinned tsgolint fork commit — updated with each lintcn release.
17
17
  // Uses remorses/tsgolint fork which adds internal/runner.Run() and
18
18
  // TSGOLINT_SNAPSHOT_CWD env var for cwd-relative test snapshots.
19
- export const DEFAULT_TSGOLINT_VERSION = '427f872946bb413f0e21cff585b6139c986c89d3';
19
+ export const DEFAULT_TSGOLINT_VERSION = 'c031d7264983dba00bae76eb03532fe3884e5667';
20
20
  // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
21
21
  // Patches from tsgolint/patches/ are applied on top during setup.
22
22
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
23
- const TYPESCRIPT_GO_COMMIT = 'c0703e66b68b826eedadce353d63fe9f4ea21fb6';
23
+ const TYPESCRIPT_GO_COMMIT = '0970dc40fa8308ca76627ffcc3a992414fdf1cf2';
24
24
  // Strict pattern for version strings — prevents path traversal via ../
25
25
  const VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/;
26
+ const CACHE_LOCK_RETRY_MS = 100;
27
+ const CACHE_LOCK_TIMEOUT_MS = 10 * 60_000;
26
28
  /** Validate version string to prevent path traversal attacks.
27
29
  * Only allows alphanumeric chars, dots, underscores, and hyphens. */
28
30
  export function validateVersion(version) {
@@ -47,6 +49,39 @@ export function getBinaryPath(contentHash) {
47
49
  export function getBuildDir(contentHash) {
48
50
  return path.join(getCacheDir(), 'build', contentHash);
49
51
  }
52
+ /** Per-hash lock directory used while generating and compiling a build workspace. */
53
+ export function getBuildLockDir(contentHash) {
54
+ return path.join(getCacheDir(), 'locks', 'build', contentHash);
55
+ }
56
+ /** Per-version lock directory used while populating cached tsgolint source. */
57
+ export function getTsgolintSourceLockDir(version) {
58
+ return path.join(getCacheDir(), 'locks', 'tsgolint', version);
59
+ }
60
+ async function wait(ms) {
61
+ await new Promise((resolve) => {
62
+ setTimeout(resolve, ms);
63
+ });
64
+ }
65
+ export async function acquireCacheLock(lockDir, description) {
66
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
67
+ const startedAt = Date.now();
68
+ while (true) {
69
+ try {
70
+ fs.mkdirSync(lockDir);
71
+ return lockDir;
72
+ }
73
+ catch (error) {
74
+ if (error?.code !== 'EEXIST') {
75
+ throw error;
76
+ }
77
+ if (Date.now() - startedAt > CACHE_LOCK_TIMEOUT_MS) {
78
+ throw new Error(`Timed out waiting for ${description}: ${lockDir}. ` +
79
+ 'If no lintcn process is running, delete this directory and retry.');
80
+ }
81
+ await wait(CACHE_LOCK_RETRY_MS);
82
+ }
83
+ }
84
+ }
50
85
  /** Download a tarball from URL and extract it to targetDir.
51
86
  * Uses the `tar` npm package for cross-platform support (no shell tar needed).
52
87
  * GitHub tarballs have a top-level directory like `repo-ref/`,
@@ -104,57 +139,64 @@ export async function ensureTsgolintSource(version) {
104
139
  if (fs.existsSync(readyMarker)) {
105
140
  return sourceDir;
106
141
  }
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
112
- if (fs.existsSync(sourceDir)) {
113
- fs.rmSync(sourceDir, { recursive: true });
114
- }
142
+ const sourceLockDir = await acquireCacheLock(getTsgolintSourceLockDir(version), `tsgolint source lock for ${version}`);
115
143
  try {
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');
122
- console.log('Downloading typescript-go...');
123
- const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`;
124
- await downloadAndExtract(tsGoUrl, tsGoDir);
125
- // apply tsgolint's patches to typescript-go
126
- const patchesDir = path.join(tmpDir, 'patches');
127
- if (fs.existsSync(patchesDir)) {
128
- const count = await applyPatches(patchesDir, tsGoDir);
129
- if (count > 0) {
130
- console.log(`Applied ${count} patches`);
144
+ if (fs.existsSync(readyMarker)) {
145
+ return sourceDir;
146
+ }
147
+ // Use a temp directory for the download, then atomic rename on success.
148
+ // This prevents concurrent processes from seeing partial state, and
149
+ // avoids the "non-empty dir on retry" problem.
150
+ const tmpDir = path.join(getCacheDir(), 'tsgolint', `.tmp-${version}-${crypto.randomBytes(4).toString('hex')}`);
151
+ // clean up any partial previous attempt
152
+ fs.rmSync(sourceDir, { recursive: true, force: true });
153
+ try {
154
+ // download tsgolint fork tarball
155
+ console.log(`Downloading tsgolint@${version.slice(0, 8)}...`);
156
+ const tsgolintUrl = `https://github.com/remorses/tsgolint/archive/${version}.tar.gz`;
157
+ await downloadAndExtract(tsgolintUrl, tmpDir);
158
+ // download typescript-go from microsoft (base commit before patches)
159
+ const tsGoDir = path.join(tmpDir, 'typescript-go');
160
+ console.log('Downloading typescript-go...');
161
+ const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`;
162
+ await downloadAndExtract(tsGoUrl, tsGoDir);
163
+ // apply tsgolint's patches to typescript-go
164
+ const patchesDir = path.join(tmpDir, 'patches');
165
+ if (fs.existsSync(patchesDir)) {
166
+ const count = await applyPatches(patchesDir, tsGoDir);
167
+ if (count > 0) {
168
+ console.log(`Applied ${count} patches`);
169
+ }
131
170
  }
171
+ // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
172
+ const collectionsDir = path.join(tmpDir, 'internal', 'collections');
173
+ const tsGoCollections = path.join(tsGoDir, 'internal', 'collections');
174
+ if (fs.existsSync(tsGoCollections)) {
175
+ fs.mkdirSync(collectionsDir, { recursive: true });
176
+ const files = fs.readdirSync(tsGoCollections).filter((f) => {
177
+ return f.endsWith('.go') && !f.endsWith('_test.go');
178
+ });
179
+ for (const file of files) {
180
+ fs.copyFileSync(path.join(tsGoCollections, file), path.join(collectionsDir, file));
181
+ }
182
+ }
183
+ // write ready marker
184
+ fs.writeFileSync(path.join(tmpDir, '.lintcn-ready'), new Date().toISOString());
185
+ // atomic rename: move completed dir to final location
186
+ fs.mkdirSync(path.dirname(sourceDir), { recursive: true });
187
+ fs.renameSync(tmpDir, sourceDir);
188
+ console.log('tsgolint source ready');
132
189
  }
133
- // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
134
- const collectionsDir = path.join(tmpDir, 'internal', 'collections');
135
- const tsGoCollections = path.join(tsGoDir, 'internal', 'collections');
136
- if (fs.existsSync(tsGoCollections)) {
137
- fs.mkdirSync(collectionsDir, { recursive: true });
138
- const files = fs.readdirSync(tsGoCollections).filter((f) => {
139
- return f.endsWith('.go') && !f.endsWith('_test.go');
140
- });
141
- for (const file of files) {
142
- fs.copyFileSync(path.join(tsGoCollections, file), path.join(collectionsDir, file));
190
+ catch (err) {
191
+ // clean up partial temp directory
192
+ if (fs.existsSync(tmpDir)) {
193
+ fs.rmSync(tmpDir, { recursive: true });
143
194
  }
195
+ throw err;
144
196
  }
145
- // write ready marker
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);
150
- console.log('tsgolint source ready');
151
197
  }
152
- catch (err) {
153
- // clean up partial temp directory
154
- if (fs.existsSync(tmpDir)) {
155
- fs.rmSync(tmpDir, { recursive: true });
156
- }
157
- throw err;
198
+ finally {
199
+ fs.rmSync(sourceLockDir, { recursive: true, force: true });
158
200
  }
159
201
  return sourceDir;
160
202
  }
@@ -1 +1 @@
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"}
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,CAuCP"}
package/dist/codegen.js CHANGED
@@ -67,15 +67,11 @@ export function generateBuildWorkspace({ buildDir, tsgolintDir, lintcnDir, rules
67
67
  fs.mkdirSync(path.join(buildDir, 'wrapper'), { recursive: true });
68
68
  // symlink tsgolint source
69
69
  const tsgolintLink = path.join(buildDir, 'tsgolint');
70
- if (fs.existsSync(tsgolintLink)) {
71
- fs.rmSync(tsgolintLink, { recursive: true });
72
- }
70
+ fs.rmSync(tsgolintLink, { recursive: true, force: true });
73
71
  fs.symlinkSync(tsgolintDir, tsgolintLink);
74
72
  // symlink user rules
75
73
  const rulesLink = path.join(buildDir, 'rules');
76
- if (fs.existsSync(rulesLink)) {
77
- fs.rmSync(rulesLink, { recursive: true });
78
- }
74
+ fs.rmSync(rulesLink, { recursive: true, force: true });
79
75
  fs.symlinkSync(path.resolve(lintcnDir), rulesLink);
80
76
  // go.work
81
77
  const goWork = `go 1.26
@@ -1 +1 @@
1
- {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/commands/lint.ts"],"names":[],"mappings":"AAwBA,wBAAsB,WAAW,CAAC,EAChC,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkElB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,EACf,WAAW,GACZ,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyClB"}
1
+ {"version":3,"file":"lint.d.ts","sourceRoot":"","sources":["../../src/commands/lint.ts"],"names":[],"mappings":"AAkCA,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,CAwFlB;AAED,wBAAsB,IAAI,CAAC,EACzB,OAAO,EACP,eAAe,EACf,eAAe,EACf,WAAW,GACZ,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,WAAW,EAAE,OAAO,CAAA;CACrB,GAAG,OAAO,CAAC,MAAM,CAAC,CAyClB"}
@@ -2,11 +2,12 @@
2
2
  // Handles Go workspace generation, compilation with caching, and execution.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import crypto from 'node:crypto';
5
6
  import { spawn } from 'node:child_process';
6
7
  import { requireLintcnDir, findLintcnDir } from "../paths.js";
7
8
  import { discoverRules } from "../discover.js";
8
9
  import { generateBuildWorkspace, generateEditorGoFiles } from "../codegen.js";
9
- import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
10
+ import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir, getBuildLockDir, acquireCacheLock, } from "../cache.js";
10
11
  import { computeContentHash } from "../hash.js";
11
12
  import { execAsync } from "../exec.js";
12
13
  async function checkGoInstalled() {
@@ -39,40 +40,57 @@ export async function buildBinary({ rebuild, tsgolintVersion, }) {
39
40
  console.log('Using cached binary');
40
41
  return getBinaryPath(contentHash);
41
42
  }
42
- // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
43
- generateEditorGoFiles(lintcnDir);
44
- // generate build workspace (per-hash dir to avoid races between concurrent processes)
45
- const buildDir = getBuildDir(contentHash);
46
- console.log('Generating build workspace...');
47
- generateBuildWorkspace({
48
- buildDir,
49
- tsgolintDir,
50
- lintcnDir,
51
- rules,
52
- });
53
- // compile
54
- const binDir = getBinDir();
55
- fs.mkdirSync(binDir, { recursive: true });
56
- const binaryPath = getBinaryPath(contentHash);
57
- // Check if any lintcn binary has been built before — if not, this is a cold
58
- // build that compiles the full tsgolint + typescript-go dependency tree.
59
- const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : [];
60
- if (existingBins.length === 0) {
61
- console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...');
62
- console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).');
63
- }
64
- else {
65
- console.log('Compiling custom tsgolint binary...');
43
+ const buildLockDir = await acquireCacheLock(getBuildLockDir(contentHash), `build lock for ${contentHash}`);
44
+ try {
45
+ if (!rebuild && cachedBinaryExists(contentHash)) {
46
+ console.log('Using cached binary');
47
+ return getBinaryPath(contentHash);
48
+ }
49
+ // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
50
+ generateEditorGoFiles(lintcnDir);
51
+ // generate build workspace (per-hash dir to avoid races between concurrent processes)
52
+ const buildDir = getBuildDir(contentHash);
53
+ console.log('Generating build workspace...');
54
+ generateBuildWorkspace({
55
+ buildDir,
56
+ tsgolintDir,
57
+ lintcnDir,
58
+ rules,
59
+ });
60
+ // compile
61
+ const binDir = getBinDir();
62
+ fs.mkdirSync(binDir, { recursive: true });
63
+ const binaryPath = getBinaryPath(contentHash);
64
+ // Check if any lintcn binary has been built before — if not, this is a cold
65
+ // build that compiles the full tsgolint + typescript-go dependency tree.
66
+ const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : [];
67
+ if (existingBins.length === 0) {
68
+ console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...');
69
+ console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).');
70
+ }
71
+ else {
72
+ console.log('Compiling custom tsgolint binary...');
73
+ }
74
+ const tmpBinaryPath = path.join(binDir, `${contentHash}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
75
+ try {
76
+ const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', tmpBinaryPath, './wrapper'], {
77
+ cwd: buildDir,
78
+ stdio: 'inherit',
79
+ });
80
+ if (buildExitCode !== 0) {
81
+ throw new Error(`Go compilation failed (exit code ${buildExitCode})`);
82
+ }
83
+ fs.renameSync(tmpBinaryPath, binaryPath);
84
+ }
85
+ finally {
86
+ fs.rmSync(tmpBinaryPath, { force: true });
87
+ }
88
+ console.log('Build complete');
89
+ return binaryPath;
66
90
  }
67
- const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', binaryPath, './wrapper'], {
68
- cwd: buildDir,
69
- stdio: 'inherit',
70
- });
71
- if (buildExitCode !== 0) {
72
- throw new Error(`Go compilation failed (exit code ${buildExitCode})`);
91
+ finally {
92
+ fs.rmSync(buildLockDir, { recursive: true, force: true });
73
93
  }
74
- console.log('Build complete');
75
- return binaryPath;
76
94
  }
77
95
  export async function lint({ rebuild, tsgolintVersion, passthroughArgs, allWarnings, }) {
78
96
  if (!findLintcnDir()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lintcn",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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",
@@ -52,6 +52,8 @@
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.0.0",
54
54
  "@types/tar": "^7.0.87",
55
+ "rimraf": "^6.0.1",
56
+ "tsx": "^4.20.6",
55
57
  "typescript": "5.8.2"
56
58
  },
57
59
  "dependencies": {
@@ -60,7 +62,8 @@
60
62
  "tar": "^7.5.12"
61
63
  },
62
64
  "scripts": {
63
- "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
64
- "cli": "tsx src/cli"
65
+ "build": "tsc && chmod +x dist/cli.js",
66
+ "cli": "tsx src/cli.ts",
67
+ "test": "cd .lintcn && TSGOLINT_SNAPSHOT_CWD=true go test -count=1 ./..."
65
68
  }
66
69
  }
package/src/cache.ts CHANGED
@@ -18,15 +18,17 @@ import { execAsync } from './exec.ts'
18
18
  // Pinned tsgolint fork commit — updated with each lintcn release.
19
19
  // Uses remorses/tsgolint fork which adds internal/runner.Run() and
20
20
  // TSGOLINT_SNAPSHOT_CWD env var for cwd-relative test snapshots.
21
- export const DEFAULT_TSGOLINT_VERSION = '427f872946bb413f0e21cff585b6139c986c89d3'
21
+ export const DEFAULT_TSGOLINT_VERSION = 'c031d7264983dba00bae76eb03532fe3884e5667'
22
22
 
23
23
  // Pinned typescript-go base commit from microsoft/typescript-go (before patches).
24
24
  // Patches from tsgolint/patches/ are applied on top during setup.
25
25
  // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
26
- const TYPESCRIPT_GO_COMMIT = 'c0703e66b68b826eedadce353d63fe9f4ea21fb6'
26
+ const TYPESCRIPT_GO_COMMIT = '0970dc40fa8308ca76627ffcc3a992414fdf1cf2'
27
27
 
28
28
  // Strict pattern for version strings — prevents path traversal via ../
29
29
  const VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/
30
+ const CACHE_LOCK_RETRY_MS = 100
31
+ const CACHE_LOCK_TIMEOUT_MS = 10 * 60_000
30
32
 
31
33
  /** Validate version string to prevent path traversal attacks.
32
34
  * Only allows alphanumeric chars, dots, underscores, and hyphens. */
@@ -60,6 +62,45 @@ export function getBuildDir(contentHash: string): string {
60
62
  return path.join(getCacheDir(), 'build', contentHash)
61
63
  }
62
64
 
65
+ /** Per-hash lock directory used while generating and compiling a build workspace. */
66
+ export function getBuildLockDir(contentHash: string): string {
67
+ return path.join(getCacheDir(), 'locks', 'build', contentHash)
68
+ }
69
+
70
+ /** Per-version lock directory used while populating cached tsgolint source. */
71
+ export function getTsgolintSourceLockDir(version: string): string {
72
+ return path.join(getCacheDir(), 'locks', 'tsgolint', version)
73
+ }
74
+
75
+ async function wait(ms: number): Promise<void> {
76
+ await new Promise((resolve) => {
77
+ setTimeout(resolve, ms)
78
+ })
79
+ }
80
+
81
+ export async function acquireCacheLock(lockDir: string, description: string): Promise<string> {
82
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true })
83
+
84
+ const startedAt = Date.now()
85
+ while (true) {
86
+ try {
87
+ fs.mkdirSync(lockDir)
88
+ return lockDir
89
+ } catch (error) {
90
+ if (error?.code !== 'EEXIST') {
91
+ throw error
92
+ }
93
+ if (Date.now() - startedAt > CACHE_LOCK_TIMEOUT_MS) {
94
+ throw new Error(
95
+ `Timed out waiting for ${description}: ${lockDir}. ` +
96
+ 'If no lintcn process is running, delete this directory and retry.',
97
+ )
98
+ }
99
+ await wait(CACHE_LOCK_RETRY_MS)
100
+ }
101
+ }
102
+ }
103
+
63
104
  /** Download a tarball from URL and extract it to targetDir.
64
105
  * Uses the `tar` npm package for cross-platform support (no shell tar needed).
65
106
  * GitHub tarballs have a top-level directory like `repo-ref/`,
@@ -126,64 +167,74 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
126
167
  return sourceDir
127
168
  }
128
169
 
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
135
- if (fs.existsSync(sourceDir)) {
136
- fs.rmSync(sourceDir, { recursive: true })
137
- }
138
-
170
+ const sourceLockDir = await acquireCacheLock(
171
+ getTsgolintSourceLockDir(version),
172
+ `tsgolint source lock for ${version}`,
173
+ )
139
174
  try {
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)
144
-
145
- // download typescript-go from microsoft (base commit before patches)
146
- const tsGoDir = path.join(tmpDir, 'typescript-go')
147
- console.log('Downloading typescript-go...')
148
- const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`
149
- await downloadAndExtract(tsGoUrl, tsGoDir)
150
-
151
- // apply tsgolint's patches to typescript-go
152
- const patchesDir = path.join(tmpDir, 'patches')
153
- if (fs.existsSync(patchesDir)) {
154
- const count = await applyPatches(patchesDir, tsGoDir)
155
- if (count > 0) {
156
- console.log(`Applied ${count} patches`)
157
- }
175
+ if (fs.existsSync(readyMarker)) {
176
+ return sourceDir
158
177
  }
159
178
 
160
- // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
161
- const collectionsDir = path.join(tmpDir, 'internal', 'collections')
162
- const tsGoCollections = path.join(tsGoDir, 'internal', 'collections')
163
- if (fs.existsSync(tsGoCollections)) {
164
- fs.mkdirSync(collectionsDir, { recursive: true })
165
- const files = fs.readdirSync(tsGoCollections).filter((f) => {
166
- return f.endsWith('.go') && !f.endsWith('_test.go')
167
- })
168
- for (const file of files) {
169
- fs.copyFileSync(path.join(tsGoCollections, file), path.join(collectionsDir, file))
179
+ // Use a temp directory for the download, then atomic rename on success.
180
+ // This prevents concurrent processes from seeing partial state, and
181
+ // avoids the "non-empty dir on retry" problem.
182
+ const tmpDir = path.join(getCacheDir(), 'tsgolint', `.tmp-${version}-${crypto.randomBytes(4).toString('hex')}`)
183
+
184
+ // clean up any partial previous attempt
185
+ fs.rmSync(sourceDir, { recursive: true, force: true })
186
+
187
+ try {
188
+ // download tsgolint fork tarball
189
+ console.log(`Downloading tsgolint@${version.slice(0, 8)}...`)
190
+ const tsgolintUrl = `https://github.com/remorses/tsgolint/archive/${version}.tar.gz`
191
+ await downloadAndExtract(tsgolintUrl, tmpDir)
192
+
193
+ // download typescript-go from microsoft (base commit before patches)
194
+ const tsGoDir = path.join(tmpDir, 'typescript-go')
195
+ console.log('Downloading typescript-go...')
196
+ const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`
197
+ await downloadAndExtract(tsGoUrl, tsGoDir)
198
+
199
+ // apply tsgolint's patches to typescript-go
200
+ const patchesDir = path.join(tmpDir, 'patches')
201
+ if (fs.existsSync(patchesDir)) {
202
+ const count = await applyPatches(patchesDir, tsGoDir)
203
+ if (count > 0) {
204
+ console.log(`Applied ${count} patches`)
205
+ }
206
+ }
207
+
208
+ // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
209
+ const collectionsDir = path.join(tmpDir, 'internal', 'collections')
210
+ const tsGoCollections = path.join(tsGoDir, 'internal', 'collections')
211
+ if (fs.existsSync(tsGoCollections)) {
212
+ fs.mkdirSync(collectionsDir, { recursive: true })
213
+ const files = fs.readdirSync(tsGoCollections).filter((f) => {
214
+ return f.endsWith('.go') && !f.endsWith('_test.go')
215
+ })
216
+ for (const file of files) {
217
+ fs.copyFileSync(path.join(tsGoCollections, file), path.join(collectionsDir, file))
218
+ }
170
219
  }
171
- }
172
220
 
173
- // write ready marker
174
- fs.writeFileSync(path.join(tmpDir, '.lintcn-ready'), new Date().toISOString())
221
+ // write ready marker
222
+ fs.writeFileSync(path.join(tmpDir, '.lintcn-ready'), new Date().toISOString())
175
223
 
176
- // atomic rename: move completed dir to final location
177
- fs.mkdirSync(path.dirname(sourceDir), { recursive: true })
178
- fs.renameSync(tmpDir, sourceDir)
224
+ // atomic rename: move completed dir to final location
225
+ fs.mkdirSync(path.dirname(sourceDir), { recursive: true })
226
+ fs.renameSync(tmpDir, sourceDir)
179
227
 
180
- console.log('tsgolint source ready')
181
- } catch (err) {
182
- // clean up partial temp directory
183
- if (fs.existsSync(tmpDir)) {
184
- fs.rmSync(tmpDir, { recursive: true })
228
+ console.log('tsgolint source ready')
229
+ } catch (err) {
230
+ // clean up partial temp directory
231
+ if (fs.existsSync(tmpDir)) {
232
+ fs.rmSync(tmpDir, { recursive: true })
233
+ }
234
+ throw err
185
235
  }
186
- throw err
236
+ } finally {
237
+ fs.rmSync(sourceLockDir, { recursive: true, force: true })
187
238
  }
188
239
 
189
240
  return sourceDir
package/src/codegen.ts CHANGED
@@ -89,16 +89,12 @@ export function generateBuildWorkspace({
89
89
 
90
90
  // symlink tsgolint source
91
91
  const tsgolintLink = path.join(buildDir, 'tsgolint')
92
- if (fs.existsSync(tsgolintLink)) {
93
- fs.rmSync(tsgolintLink, { recursive: true })
94
- }
92
+ fs.rmSync(tsgolintLink, { recursive: true, force: true })
95
93
  fs.symlinkSync(tsgolintDir, tsgolintLink)
96
94
 
97
95
  // symlink user rules
98
96
  const rulesLink = path.join(buildDir, 'rules')
99
- if (fs.existsSync(rulesLink)) {
100
- fs.rmSync(rulesLink, { recursive: true })
101
- }
97
+ fs.rmSync(rulesLink, { recursive: true, force: true })
102
98
  fs.symlinkSync(path.resolve(lintcnDir), rulesLink)
103
99
 
104
100
  // go.work
@@ -3,11 +3,21 @@
3
3
 
4
4
  import fs from 'node:fs'
5
5
  import path from 'node:path'
6
+ import crypto from 'node:crypto'
6
7
  import { spawn } from 'node:child_process'
7
8
  import { requireLintcnDir, findLintcnDir } from '../paths.ts'
8
9
  import { discoverRules, type RuleMetadata } from '../discover.ts'
9
10
  import { generateBuildWorkspace, generateEditorGoFiles } from '../codegen.ts'
10
- import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
11
+ import {
12
+ ensureTsgolintSource,
13
+ validateVersion,
14
+ cachedBinaryExists,
15
+ getBinaryPath,
16
+ getBuildDir,
17
+ getBinDir,
18
+ getBuildLockDir,
19
+ acquireCacheLock,
20
+ } from '../cache.ts'
11
21
  import { computeContentHash } from '../hash.ts'
12
22
  import { execAsync } from '../exec.ts'
13
23
 
@@ -56,44 +66,66 @@ export async function buildBinary({
56
66
  return getBinaryPath(contentHash)
57
67
  }
58
68
 
59
- // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
60
- generateEditorGoFiles(lintcnDir)
69
+ const buildLockDir = await acquireCacheLock(
70
+ getBuildLockDir(contentHash),
71
+ `build lock for ${contentHash}`,
72
+ )
73
+ try {
74
+ if (!rebuild && cachedBinaryExists(contentHash)) {
75
+ console.log('Using cached binary')
76
+ return getBinaryPath(contentHash)
77
+ }
61
78
 
62
- // generate build workspace (per-hash dir to avoid races between concurrent processes)
63
- const buildDir = getBuildDir(contentHash)
64
- console.log('Generating build workspace...')
65
- generateBuildWorkspace({
66
- buildDir,
67
- tsgolintDir,
68
- lintcnDir,
69
- rules,
70
- })
79
+ // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
80
+ generateEditorGoFiles(lintcnDir)
81
+
82
+ // generate build workspace (per-hash dir to avoid races between concurrent processes)
83
+ const buildDir = getBuildDir(contentHash)
84
+ console.log('Generating build workspace...')
85
+ generateBuildWorkspace({
86
+ buildDir,
87
+ tsgolintDir,
88
+ lintcnDir,
89
+ rules,
90
+ })
71
91
 
72
- // compile
73
- const binDir = getBinDir()
74
- fs.mkdirSync(binDir, { recursive: true })
75
- const binaryPath = getBinaryPath(contentHash)
76
-
77
- // Check if any lintcn binary has been built before — if not, this is a cold
78
- // build that compiles the full tsgolint + typescript-go dependency tree.
79
- const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : []
80
- if (existingBins.length === 0) {
81
- console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...')
82
- console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).')
83
- } else {
84
- console.log('Compiling custom tsgolint binary...')
85
- }
92
+ // compile
93
+ const binDir = getBinDir()
94
+ fs.mkdirSync(binDir, { recursive: true })
95
+ const binaryPath = getBinaryPath(contentHash)
96
+
97
+ // Check if any lintcn binary has been built before — if not, this is a cold
98
+ // build that compiles the full tsgolint + typescript-go dependency tree.
99
+ const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : []
100
+ if (existingBins.length === 0) {
101
+ console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...')
102
+ console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).')
103
+ } else {
104
+ console.log('Compiling custom tsgolint binary...')
105
+ }
86
106
 
87
- const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', binaryPath, './wrapper'], {
88
- cwd: buildDir,
89
- stdio: 'inherit',
90
- })
91
- if (buildExitCode !== 0) {
92
- throw new Error(`Go compilation failed (exit code ${buildExitCode})`)
93
- }
107
+ const tmpBinaryPath = path.join(
108
+ binDir,
109
+ `${contentHash}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`,
110
+ )
111
+ try {
112
+ const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', tmpBinaryPath, './wrapper'], {
113
+ cwd: buildDir,
114
+ stdio: 'inherit',
115
+ })
116
+ if (buildExitCode !== 0) {
117
+ throw new Error(`Go compilation failed (exit code ${buildExitCode})`)
118
+ }
119
+ fs.renameSync(tmpBinaryPath, binaryPath)
120
+ } finally {
121
+ fs.rmSync(tmpBinaryPath, { force: true })
122
+ }
94
123
 
95
- console.log('Build complete')
96
- return binaryPath
124
+ console.log('Build complete')
125
+ return binaryPath
126
+ } finally {
127
+ fs.rmSync(buildLockDir, { recursive: true, force: true })
128
+ }
97
129
  }
98
130
 
99
131
  export async function lint({