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 +32 -0
- package/dist/cache.d.ts +6 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +89 -47
- package/dist/codegen.d.ts.map +1 -1
- package/dist/codegen.js +2 -6
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +51 -33
- package/package.json +6 -3
- package/src/cache.ts +103 -52
- package/src/codegen.ts +2 -6
- package/src/commands/lint.ts +67 -35
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 = "
|
|
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
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,wBAAwB,6CAA6C,CAAA;
|
|
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 = '
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
}
|
package/dist/codegen.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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"}
|
package/dist/commands/lint.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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.
|
|
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": "
|
|
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 = '
|
|
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 = '
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
|
|
221
|
+
// write ready marker
|
|
222
|
+
fs.writeFileSync(path.join(tmpDir, '.lintcn-ready'), new Date().toISOString())
|
|
175
223
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/commands/lint.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
buildDir
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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({
|