lintcn 0.2.0 → 0.3.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.
@@ -6,32 +6,48 @@ import path from 'node:path';
6
6
  import { getLintcnDir } from "../paths.js";
7
7
  import { generateEditorGoFiles } from "../codegen.js";
8
8
  import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from "../cache.js";
9
+ /** Convert GitHub blob URLs to raw.githubusercontent.com.
10
+ * Handles branch names containing slashes (e.g. feature/x) by splitting
11
+ * on /blob/ then finding the file path from the end (must end in .go). */
9
12
  function normalizeGithubUrl(url) {
10
- // Convert github.com/user/repo/blob/branch/path to raw.githubusercontent.com
11
- const blobMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/);
12
- if (blobMatch) {
13
- const [, owner, repo, branch, filePath] = blobMatch;
14
- return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
13
+ const blobSplit = url.match(/^(https?:\/\/github\.com\/[^/]+\/[^/]+)\/blob\/(.+)$/);
14
+ if (!blobSplit) {
15
+ return url;
15
16
  }
16
- return url;
17
+ const [, repoUrl, refAndPath] = blobSplit;
18
+ // repoUrl = "https://github.com/owner/repo"
19
+ // refAndPath = "feature/x/rules/my_rule.go" or "main/rules/my_rule.go"
20
+ // Extract owner/repo from repoUrl
21
+ const repoMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)$/);
22
+ if (!repoMatch) {
23
+ return url;
24
+ }
25
+ const [, owner, repo] = repoMatch;
26
+ // For raw.githubusercontent.com, the format is owner/repo/ref/path.
27
+ // We can pass refAndPath directly since GitHub resolves it.
28
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${refAndPath}`;
17
29
  }
18
30
  function deriveTestUrl(rawUrl) {
19
31
  return rawUrl.replace(/\.go$/, '_test.go');
20
32
  }
21
33
  async function fetchFile(url) {
34
+ const response = await fetch(url);
35
+ if (!response.ok) {
36
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
37
+ }
38
+ return response.text();
39
+ }
40
+ async function tryFetchFile(url) {
22
41
  try {
23
- const response = await fetch(url);
24
- if (!response.ok) {
25
- return null;
26
- }
27
- return await response.text();
42
+ return await fetchFile(url);
28
43
  }
29
44
  catch {
30
45
  return null;
31
46
  }
32
47
  }
33
48
  function rewritePackageName(content) {
34
- // Rewrite first package declaration to package lintcn
49
+ // Rewrite first package declaration to package lintcn.
50
+ // Only matches before the first import or func to avoid touching comments.
35
51
  return content.replace(/^package\s+\w+/m, 'package lintcn');
36
52
  }
37
53
  function ensureSourceComment(content, sourceUrl) {
@@ -56,9 +72,6 @@ export async function addRule(url) {
56
72
  const rawUrl = normalizeGithubUrl(url);
57
73
  console.log(`Fetching ${rawUrl}...`);
58
74
  const content = await fetchFile(rawUrl);
59
- if (!content) {
60
- throw new Error(`Could not fetch rule from ${rawUrl}`);
61
- }
62
75
  // validate it looks like a Go file with a rule
63
76
  if (!content.includes('rule.Rule')) {
64
77
  console.warn('Warning: no rule.Rule reference found in this file. Are you sure this is a tsgolint rule?');
@@ -82,7 +95,7 @@ export async function addRule(url) {
82
95
  console.log(`Added ${fileName}`);
83
96
  // try to fetch matching test file
84
97
  const testUrl = deriveTestUrl(rawUrl);
85
- const testContent = await fetchFile(testUrl);
98
+ const testContent = await tryFetchFile(testUrl);
86
99
  if (testContent) {
87
100
  const testFileName = fileName.replace(/\.go$/, '_test.go');
88
101
  const testProcessed = rewritePackageName(testContent);
@@ -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,CAoDlB;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,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"}
@@ -2,7 +2,7 @@
2
2
  // Handles Go workspace generation, compilation with caching, and execution.
3
3
  import fs from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
- import { getLintcnDir } from "../paths.js";
5
+ import { requireLintcnDir } from "../paths.js";
6
6
  import { discoverRules } from "../discover.js";
7
7
  import { generateBuildWorkspace } from "../codegen.js";
8
8
  import { ensureTsgolintSource, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
@@ -19,10 +19,7 @@ async function checkGoInstalled() {
19
19
  }
20
20
  export async function buildBinary({ rebuild, tsgolintVersion, }) {
21
21
  await checkGoInstalled();
22
- const lintcnDir = getLintcnDir();
23
- if (!fs.existsSync(lintcnDir)) {
24
- throw new Error('No .lintcn/ directory found. Run `lintcn add <url>` first.');
25
- }
22
+ const lintcnDir = requireLintcnDir();
26
23
  const rules = discoverRules(lintcnDir);
27
24
  if (rules.length === 0) {
28
25
  throw new Error('No rules found in .lintcn/. Run `lintcn add <url>` to add rules.');
@@ -1 +1 @@
1
- {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAMA,wBAAgB,SAAS,IAAI,IAAI,CA0BhC"}
1
+ {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,IAAI,IAAI,CA0BhC"}
@@ -1,10 +1,9 @@
1
1
  // lintcn list — list installed rules with metadata from .lintcn/
2
- import fs from 'node:fs';
3
- import { getLintcnDir } from "../paths.js";
2
+ import { findLintcnDir } from "../paths.js";
4
3
  import { discoverRules } from "../discover.js";
5
4
  export function listRules() {
6
- const lintcnDir = getLintcnDir();
7
- if (!fs.existsSync(lintcnDir)) {
5
+ const lintcnDir = findLintcnDir();
6
+ if (!lintcnDir) {
8
7
  console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.');
9
8
  return;
10
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../../src/commands/remove.ts"],"names":[],"mappings":"AAOA,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAiC7C"}
1
+ {"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../../src/commands/remove.ts"],"names":[],"mappings":"AAOA,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CA6B7C"}
@@ -1,13 +1,10 @@
1
1
  // lintcn remove <name> — delete a rule and its test file from .lintcn/
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { getLintcnDir } from "../paths.js";
4
+ import { requireLintcnDir } from "../paths.js";
5
5
  import { discoverRules } from "../discover.js";
6
6
  export function removeRule(name) {
7
- const lintcnDir = getLintcnDir();
8
- if (!fs.existsSync(lintcnDir)) {
9
- throw new Error('No .lintcn/ directory found.');
10
- }
7
+ const lintcnDir = requireLintcnDir();
11
8
  // match by lintcn:name metadata or by filename
12
9
  const rules = discoverRules(lintcnDir);
13
10
  const normalizedName = name.replace(/-/g, '_');
@@ -1 +1 @@
1
- {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,YAAY;IAC3B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAA;IACZ,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAA;CACjB;AAKD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMrE;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhE;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAiC/D"}
1
+ {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,YAAY;IAC3B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAA;IACZ,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,MAAM,EAAE,MAAM,CAAA;IACd,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAA;CACjB;AAOD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMrE;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhE;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,EAAE,CAwC/D"}
package/dist/discover.js CHANGED
@@ -2,7 +2,9 @@
2
2
  // Returns structured info about each discovered rule for codegen and list display.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- const RULE_VAR_RE = /^var\s+(\w+)\s*=\s*rule\.Rule\s*\{/m;
5
+ // Matches `var XxxRule = rule.Rule{` with optional leading whitespace
6
+ // and optional import alias (e.g. `r.Rule{` if imported as `r "...rule"`)
7
+ const RULE_VAR_RE = /^\s*var\s+(\w+)\s*=\s*\w*\.?Rule\s*\{/m;
6
8
  const METADATA_RE = /^\/\/\s*lintcn:(\w+)\s+(.+)$/gm;
7
9
  export function parseMetadata(content) {
8
10
  const meta = {};
@@ -28,6 +30,11 @@ export function discoverRules(lintcnDir) {
28
30
  const content = fs.readFileSync(filePath, 'utf-8');
29
31
  const varName = parseRuleVar(content);
30
32
  if (!varName) {
33
+ // warn if file contains rule.Rule but we couldn't parse the var name
34
+ if (content.includes('rule.Rule')) {
35
+ console.warn(`Warning: ${fileName} contains rule.Rule but no exported var was found. ` +
36
+ `Expected pattern: var XxxRule = rule.Rule{`);
37
+ }
31
38
  continue;
32
39
  }
33
40
  const meta = parseMetadata(content);
@@ -1 +1 @@
1
- {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AASA,wBAAsB,kBAAkB,CAAC,EACvC,SAAS,EACT,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CA6BlB"}
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAaA,wBAAsB,kBAAkB,CAAC,EACvC,SAAS,EACT,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,MAAM,CAAC,CA8BlB"}
package/dist/hash.js CHANGED
@@ -1,12 +1,16 @@
1
1
  // Content hash for binary caching.
2
- // Combines tsgolint version, rule file contents, Go version, and platform
3
- // into a single SHA-256 hash used as the cached binary filename.
2
+ // Combines cache schema version, tsgolint version, rule file contents,
3
+ // Go version, and platform into a single SHA-256 hash.
4
+ // Bump CACHE_SCHEMA_VERSION when codegen logic changes to invalidate
5
+ // stale binaries built by older lintcn versions.
4
6
  import crypto from 'node:crypto';
5
7
  import fs from 'node:fs';
6
8
  import path from 'node:path';
7
9
  import { execAsync } from "./exec.js";
10
+ const CACHE_SCHEMA_VERSION = '2';
8
11
  export async function computeContentHash({ lintcnDir, tsgolintVersion, }) {
9
12
  const hash = crypto.createHash('sha256');
13
+ hash.update(`cache-schema:${CACHE_SCHEMA_VERSION}\n`);
10
14
  hash.update(`tsgolint:${tsgolintVersion}\n`);
11
15
  hash.update(`platform:${process.platform}-${process.arch}\n`);
12
16
  // add Go version
package/dist/index.d.ts CHANGED
@@ -5,4 +5,5 @@ export { lint, buildBinary } from './commands/lint.ts';
5
5
  export { listRules } from './commands/list.ts';
6
6
  export { removeRule } from './commands/remove.ts';
7
7
  export { DEFAULT_TSGOLINT_VERSION } from './cache.ts';
8
+ export { findLintcnDir, getLintcnDir, requireLintcnDir } from './paths.ts';
8
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"}
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"}
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export { lint, buildBinary } from "./commands/lint.js";
4
4
  export { listRules } from "./commands/list.js";
5
5
  export { removeRule } from "./commands/remove.js";
6
6
  export { DEFAULT_TSGOLINT_VERSION } from "./cache.js";
7
+ export { findLintcnDir, getLintcnDir, requireLintcnDir } from "./paths.js";
package/dist/paths.d.ts CHANGED
@@ -1,2 +1,8 @@
1
+ /** Find the nearest .lintcn/ directory by walking up from cwd.
2
+ * Returns the absolute path to the directory, or null if not found. */
3
+ export declare function findLintcnDir(): string | null;
4
+ /** Find .lintcn/ or throw with a helpful error. */
1
5
  export declare function getLintcnDir(): string;
6
+ /** Find .lintcn/ or throw — for commands that require it to exist. */
7
+ export declare function requireLintcnDir(): string;
2
8
  //# sourceMappingURL=paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAIA,wBAAgB,YAAY,IAAI,MAAM,CAErC"}
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAMA;wEACwE;AACxE,wBAAgB,aAAa,IAAI,MAAM,GAAG,IAAI,CAG7C;AAED,mDAAmD;AACnD,wBAAgB,YAAY,IAAI,MAAM,CAOrC;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,IAAI,MAAM,CAMzC"}
package/dist/paths.js CHANGED
@@ -1,5 +1,27 @@
1
- // Resolve the .lintcn/ directory path relative to cwd.
1
+ // Resolve the .lintcn/ directory by walking up from cwd.
2
+ // This lets users run `lintcn lint` from any subdirectory of their project.
3
+ import { findUpSync } from 'find-up';
2
4
  import path from 'node:path';
5
+ /** Find the nearest .lintcn/ directory by walking up from cwd.
6
+ * Returns the absolute path to the directory, or null if not found. */
7
+ export function findLintcnDir() {
8
+ const found = findUpSync('.lintcn', { type: 'directory' });
9
+ return found ?? null;
10
+ }
11
+ /** Find .lintcn/ or throw with a helpful error. */
3
12
  export function getLintcnDir() {
13
+ const dir = findLintcnDir();
14
+ if (dir) {
15
+ return dir;
16
+ }
17
+ // fall back to cwd/.lintcn for `lintcn add` (creates the directory)
4
18
  return path.resolve(process.cwd(), '.lintcn');
5
19
  }
20
+ /** Find .lintcn/ or throw — for commands that require it to exist. */
21
+ export function requireLintcnDir() {
22
+ const dir = findLintcnDir();
23
+ if (!dir) {
24
+ throw new Error('No .lintcn/ directory found in current or parent directories. Run `lintcn add <url>` first.');
25
+ }
26
+ return dir;
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lintcn",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -54,6 +54,7 @@
54
54
  "typescript": "5.8.2"
55
55
  },
56
56
  "dependencies": {
57
+ "find-up": "^8.0.0",
57
58
  "goke": "^6.3.0"
58
59
  },
59
60
  "scripts": {
package/src/cache.ts CHANGED
@@ -1,11 +1,16 @@
1
- // Manage cached tsgolint source clone and compiled binaries.
2
- // Cache lives in ~/.cache/lintcn/ with structure:
3
- // tsgolint/<version>/ — cloned tsgolint source (read-only)
4
- // bin/<content-hash> — compiled binaries
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.
4
+ //
5
+ // Cache layout:
6
+ // ~/.cache/lintcn/tsgolint/<version>/ — extracted source (read-only)
7
+ // ~/.cache/lintcn/bin/<content-hash> — compiled binaries
5
8
 
6
9
  import fs from 'node:fs'
7
10
  import os from 'node:os'
8
11
  import path from 'node:path'
12
+ import { pipeline } from 'node:stream/promises'
13
+ import { createGunzip } from 'node:zlib'
9
14
  import { execAsync } from './exec.ts'
10
15
 
11
16
  // Pinned tsgolint version — updated with each lintcn release.
@@ -14,6 +19,11 @@ import { execAsync } from './exec.ts'
14
19
  // decision — tsgolint API changes can break user rules.
15
20
  export const DEFAULT_TSGOLINT_VERSION = 'v0.9.2'
16
21
 
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.
24
+ // Must be updated when DEFAULT_TSGOLINT_VERSION changes.
25
+ const TYPESCRIPT_GO_COMMIT = '2437fa43e85103d2a18e8e41e1a2a994d0708ccf'
26
+
17
27
  export function getCacheDir(): string {
18
28
  return path.join(os.homedir(), '.cache', 'lintcn')
19
29
  }
@@ -34,6 +44,43 @@ export function getBuildDir(): string {
34
44
  return path.join(getCacheDir(), 'build')
35
45
  }
36
46
 
47
+ /** Download a tarball from URL and extract it to targetDir.
48
+ * GitHub tarballs have a top-level directory like `repo-ref/`,
49
+ * so we strip the first path component during extraction. */
50
+ async function downloadAndExtract(url: string, targetDir: string): Promise<void> {
51
+ const response = await fetch(url)
52
+ if (!response.ok || !response.body) {
53
+ throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`)
54
+ }
55
+
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 })
66
+ }
67
+
68
+ /** Apply git-format patches using `patch -p1` (no git required).
69
+ * Patches are standard unified diff format, `patch` ignores the git metadata. */
70
+ async function applyPatches(patchesDir: string, targetDir: string): Promise<number> {
71
+ const patches = fs.readdirSync(patchesDir)
72
+ .filter((f) => { return f.endsWith('.patch') })
73
+ .sort()
74
+
75
+ for (const patchFile of patches) {
76
+ const patchPath = path.join(patchesDir, patchFile)
77
+ // --batch silences interactive prompts, -f forces application
78
+ await execAsync('patch', ['-p1', '--batch', '-i', patchPath], { cwd: targetDir })
79
+ }
80
+
81
+ return patches.length
82
+ }
83
+
37
84
  export async function ensureTsgolintSource(version: string): Promise<string> {
38
85
  const sourceDir = getTsgolintSourceDir(version)
39
86
  const readyMarker = path.join(sourceDir, '.lintcn-ready')
@@ -42,56 +89,56 @@ export async function ensureTsgolintSource(version: string): Promise<string> {
42
89
  return sourceDir
43
90
  }
44
91
 
45
- console.log(`Cloning tsgolint@${version}...`)
46
-
47
- fs.mkdirSync(sourceDir, { recursive: true })
48
-
49
- // clone with depth 1 for speed — --branch works with tags and branches
50
- const cloneArgs = [
51
- 'clone', '--depth', '1',
52
- '--branch', version,
53
- '--recurse-submodules', '--shallow-submodules',
54
- 'https://github.com/oxc-project/tsgolint.git', sourceDir,
55
- ]
56
-
57
- await execAsync('git', cloneArgs)
92
+ // clean up any partial previous attempt so we start fresh
93
+ if (fs.existsSync(sourceDir)) {
94
+ fs.rmSync(sourceDir, { recursive: true })
95
+ }
58
96
 
59
- // apply patches if they exist
60
- const patchesDir = path.join(sourceDir, 'patches')
61
- if (fs.existsSync(patchesDir)) {
62
- const patches = fs.readdirSync(patchesDir).filter((f) => {
63
- return f.endsWith('.patch')
64
- }).sort()
97
+ 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)
102
+
103
+ // download typescript-go source tarball into tsgolint/typescript-go/
104
+ const tsGoDir = path.join(sourceDir, 'typescript-go')
105
+ console.log('Downloading typescript-go...')
106
+ const tsGoUrl = `https://github.com/microsoft/typescript-go/archive/${TYPESCRIPT_GO_COMMIT}.tar.gz`
107
+ await downloadAndExtract(tsGoUrl, tsGoDir)
108
+
109
+ // apply patches to typescript-go
110
+ const patchesDir = path.join(sourceDir, 'patches')
111
+ if (fs.existsSync(patchesDir)) {
112
+ const count = await applyPatches(patchesDir, tsGoDir)
113
+ if (count > 0) {
114
+ console.log(`Applied ${count} patches`)
115
+ }
116
+ }
65
117
 
66
- if (patches.length > 0) {
67
- console.log(`Applying ${patches.length} patches...`)
68
- const patchPaths = patches.map((p) => {
69
- return path.join('..', 'patches', p)
70
- })
71
- await execAsync('git', ['am', '--3way', '--no-gpg-sign', ...patchPaths], {
72
- cwd: path.join(sourceDir, 'typescript-go'),
118
+ // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
119
+ const collectionsDir = path.join(sourceDir, 'internal', 'collections')
120
+ const tsGoCollections = path.join(tsGoDir, 'internal', 'collections')
121
+ if (fs.existsSync(tsGoCollections)) {
122
+ fs.mkdirSync(collectionsDir, { recursive: true })
123
+ const files = fs.readdirSync(tsGoCollections).filter((f) => {
124
+ return f.endsWith('.go') && !f.endsWith('_test.go')
73
125
  })
126
+ for (const file of files) {
127
+ fs.copyFileSync(path.join(tsGoCollections, file), path.join(collectionsDir, file))
128
+ }
74
129
  }
75
- }
76
130
 
77
- // copy internal/collections from typescript-go (required by tsgolint, done by `just init`)
78
- const collectionsDir = path.join(sourceDir, 'internal', 'collections')
79
- const tsGoCollections = path.join(sourceDir, 'typescript-go', 'internal', 'collections')
80
- if (!fs.existsSync(collectionsDir) && fs.existsSync(tsGoCollections)) {
81
- fs.mkdirSync(collectionsDir, { recursive: true })
82
- const files = fs.readdirSync(tsGoCollections).filter((f) => {
83
- return f.endsWith('.go') && !f.endsWith('_test.go')
84
- })
85
- for (const file of files) {
86
- fs.copyFileSync(path.join(tsGoCollections, file), path.join(collectionsDir, file))
131
+ // write ready marker
132
+ fs.writeFileSync(readyMarker, new Date().toISOString())
133
+ console.log('tsgolint source ready')
134
+ } catch (err) {
135
+ // clean up partial download so next run starts fresh
136
+ if (fs.existsSync(sourceDir)) {
137
+ fs.rmSync(sourceDir, { recursive: true })
87
138
  }
88
- console.log(`Copied ${files.length} collection files`)
139
+ throw err
89
140
  }
90
141
 
91
- // write ready marker
92
- fs.writeFileSync(readyMarker, new Date().toISOString())
93
- console.log('tsgolint source ready')
94
-
95
142
  return sourceDir
96
143
  }
97
144