lintcn 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +70 -18
- package/dist/cli.js +11 -5
- package/dist/codegen.js +20 -3
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +106 -75
- package/dist/commands/clean.d.ts +2 -0
- package/dist/commands/clean.d.ts.map +1 -0
- package/dist/commands/clean.js +44 -0
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +19 -4
- package/dist/commands/remove.d.ts.map +1 -1
- package/dist/commands/remove.js +6 -14
- package/dist/discover.d.ts +3 -3
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +32 -23
- package/dist/hash.d.ts +7 -1
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js +28 -25
- package/package.json +1 -1
- package/src/cli.ts +12 -5
- package/src/codegen.ts +22 -3
- package/src/commands/add.ts +129 -71
- package/src/commands/clean.ts +48 -0
- package/src/commands/lint.ts +20 -4
- package/src/commands/remove.ts +6 -16
- package/src/discover.ts +39 -30
- package/src/hash.ts +33 -27
package/dist/commands/remove.js
CHANGED
|
@@ -1,28 +1,20 @@
|
|
|
1
|
-
// lintcn remove <name> — delete a rule
|
|
1
|
+
// lintcn remove <name> — delete a rule subfolder from .lintcn/
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { requireLintcnDir } from "../paths.js";
|
|
5
5
|
import { discoverRules } from "../discover.js";
|
|
6
6
|
export function removeRule(name) {
|
|
7
7
|
const lintcnDir = requireLintcnDir();
|
|
8
|
-
// match by lintcn:name metadata or by filename
|
|
9
8
|
const rules = discoverRules(lintcnDir);
|
|
10
9
|
const normalizedName = name.replace(/-/g, '_');
|
|
11
10
|
const match = rules.find((r) => {
|
|
12
|
-
return r.name === name || r.
|
|
11
|
+
return r.name === name || r.packageName === normalizedName;
|
|
13
12
|
});
|
|
14
13
|
if (!match) {
|
|
15
14
|
throw new Error(`Rule "${name}" not found. Run \`lintcn list\` to see installed rules.`);
|
|
16
15
|
}
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
fs.rmSync(
|
|
20
|
-
console.log(`Removed ${match.
|
|
21
|
-
// delete test file if exists
|
|
22
|
-
const testFileName = match.fileName.replace(/\.go$/, '_test.go');
|
|
23
|
-
const testPath = path.join(lintcnDir, testFileName);
|
|
24
|
-
if (fs.existsSync(testPath)) {
|
|
25
|
-
fs.rmSync(testPath);
|
|
26
|
-
console.log(`Removed ${testFileName}`);
|
|
27
|
-
}
|
|
16
|
+
// Remove the entire subfolder
|
|
17
|
+
const ruleDir = path.join(lintcnDir, match.packageName);
|
|
18
|
+
fs.rmSync(ruleDir, { recursive: true });
|
|
19
|
+
console.log(`Removed ${match.packageName}/`);
|
|
28
20
|
}
|
package/dist/discover.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface RuleMetadata {
|
|
2
|
-
/** kebab-case rule name from // lintcn:name or derived from
|
|
2
|
+
/** kebab-case rule name from // lintcn:name or derived from folder name */
|
|
3
3
|
name: string;
|
|
4
4
|
/** one-line description from // lintcn:description */
|
|
5
5
|
description: string;
|
|
@@ -7,8 +7,8 @@ export interface RuleMetadata {
|
|
|
7
7
|
source: string;
|
|
8
8
|
/** exported Go variable name like NoFloatingPromisesRule */
|
|
9
9
|
varName: string;
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/** Go package name (= subfolder name, e.g. no_floating_promises) */
|
|
11
|
+
packageName: string;
|
|
12
12
|
}
|
|
13
13
|
export declare function parseMetadata(content: string): Record<string, string>;
|
|
14
14
|
export declare function parseRuleVar(content: string): string | undefined;
|
package/dist/discover.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,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,oEAAoE;IACpE,WAAW,EAAE,MAAM,CAAA;CACpB;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,CAgD/D"}
|
package/dist/discover.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Scan .lintcn
|
|
1
|
+
// Scan .lintcn/*/ subfolders for rule.Rule variables and lintcn: metadata comments.
|
|
2
|
+
// Each subfolder is a Go package containing one rule with its companions.
|
|
2
3
|
// Returns structured info about each discovered rule for codegen and list display.
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -21,31 +22,39 @@ export function discoverRules(lintcnDir) {
|
|
|
21
22
|
if (!fs.existsSync(lintcnDir)) {
|
|
22
23
|
return [];
|
|
23
24
|
}
|
|
24
|
-
const files = fs.readdirSync(lintcnDir).filter((f) => {
|
|
25
|
-
return f.endsWith('.go') && !f.endsWith('_test.go');
|
|
26
|
-
});
|
|
27
25
|
const rules = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
const entries = fs.readdirSync(lintcnDir, { withFileTypes: true });
|
|
27
|
+
// Warn about flat .go files that should be in subfolders
|
|
28
|
+
const flatGoFiles = entries.filter((e) => {
|
|
29
|
+
return e.isFile() && e.name.endsWith('.go');
|
|
30
|
+
});
|
|
31
|
+
for (const file of flatGoFiles) {
|
|
32
|
+
const baseName = file.name.replace(/(_test)?\.go$/, '');
|
|
33
|
+
console.error(`Error: ${file.name} is a flat file in .lintcn/ — rules must be in subfolders.\n` +
|
|
34
|
+
` Move it to .lintcn/${baseName}/${file.name} and change the package to "${baseName}".\n`);
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
38
38
|
continue;
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
rules.push({
|
|
43
|
-
name: meta.name || baseName.replace(/_/g, '-'),
|
|
44
|
-
description: meta.description || '',
|
|
45
|
-
source: meta.source || '',
|
|
46
|
-
varName,
|
|
47
|
-
fileName,
|
|
39
|
+
const subDir = path.join(lintcnDir, entry.name);
|
|
40
|
+
const goFiles = fs.readdirSync(subDir).filter((f) => {
|
|
41
|
+
return f.endsWith('.go') && !f.endsWith('_test.go');
|
|
48
42
|
});
|
|
43
|
+
for (const fileName of goFiles) {
|
|
44
|
+
const filePath = path.join(subDir, fileName);
|
|
45
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
46
|
+
const varName = parseRuleVar(content);
|
|
47
|
+
if (!varName)
|
|
48
|
+
continue;
|
|
49
|
+
const meta = parseMetadata(content);
|
|
50
|
+
rules.push({
|
|
51
|
+
name: meta.name || entry.name.replace(/_/g, '-'),
|
|
52
|
+
description: meta.description || '',
|
|
53
|
+
source: meta.source || '',
|
|
54
|
+
varName,
|
|
55
|
+
packageName: entry.name,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
49
58
|
}
|
|
50
59
|
return rules;
|
|
51
60
|
}
|
package/dist/hash.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
/** Compute a deterministic content hash for binary caching.
|
|
2
|
+
* Returns: { short: "a1b2c3d4..." (16 hex), full: "a1b2c3d4..." (64 hex) }
|
|
3
|
+
* The short hash is used for local cache paths, the full hash for remote cache keys. */
|
|
1
4
|
export declare function computeContentHash({ lintcnDir, tsgolintVersion, }: {
|
|
2
5
|
lintcnDir: string;
|
|
3
6
|
tsgolintVersion: string;
|
|
4
|
-
}): Promise<
|
|
7
|
+
}): Promise<{
|
|
8
|
+
short: string;
|
|
9
|
+
full: string;
|
|
10
|
+
}>;
|
|
5
11
|
//# sourceMappingURL=hash.d.ts.map
|
package/dist/hash.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAiBA;;yFAEyF;AACzF,wBAAsB,kBAAkB,CAAC,EACvC,SAAS,EACT,eAAe,GAChB,EAAE;IACD,SAAS,EAAE,MAAM,CAAA;IACjB,eAAe,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA2B3C"}
|
package/dist/hash.js
CHANGED
|
@@ -1,37 +1,40 @@
|
|
|
1
|
-
// Content hash for binary caching.
|
|
2
|
-
// Combines cache schema version, tsgolint version,
|
|
3
|
-
//
|
|
1
|
+
// Content hash for binary caching (local + remote).
|
|
2
|
+
// Combines cache schema version, tsgolint version, platform triplet,
|
|
3
|
+
// and non-test rule file contents into a SHA-256 hash.
|
|
4
|
+
//
|
|
5
|
+
// The hash is deterministic across machines — same rules + same tsgolint
|
|
6
|
+
// version + same platform = same hash. Go version is NOT included because
|
|
7
|
+
// the compiled binary is standalone (no Go runtime dependency).
|
|
8
|
+
//
|
|
4
9
|
// Bump CACHE_SCHEMA_VERSION when codegen logic changes to invalidate
|
|
5
10
|
// stale binaries built by older lintcn versions.
|
|
6
11
|
import crypto from 'node:crypto';
|
|
7
12
|
import fs from 'node:fs';
|
|
8
13
|
import path from 'node:path';
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
const CACHE_SCHEMA_VERSION = '4';
|
|
15
|
+
/** Compute a deterministic content hash for binary caching.
|
|
16
|
+
* Returns: { short: "a1b2c3d4..." (16 hex), full: "a1b2c3d4..." (64 hex) }
|
|
17
|
+
* The short hash is used for local cache paths, the full hash for remote cache keys. */
|
|
11
18
|
export async function computeContentHash({ lintcnDir, tsgolintVersion, }) {
|
|
12
19
|
const hash = crypto.createHash('sha256');
|
|
13
20
|
hash.update(`cache-schema:${CACHE_SCHEMA_VERSION}\n`);
|
|
14
21
|
hash.update(`tsgolint:${tsgolintVersion}\n`);
|
|
15
22
|
hash.update(`platform:${process.platform}-${process.arch}\n`);
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
// walk rule subfolders for non-test .go files in sorted order
|
|
24
|
+
const entries = fs.readdirSync(lintcnDir, { withFileTypes: true })
|
|
25
|
+
.filter((e) => { return e.isDirectory() && !e.name.startsWith('.'); })
|
|
26
|
+
.sort((a, b) => { return a.name.localeCompare(b.name); });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const subDir = path.join(lintcnDir, entry.name);
|
|
29
|
+
const goFiles = fs.readdirSync(subDir)
|
|
30
|
+
.filter((f) => { return f.endsWith('.go') && !f.endsWith('_test.go'); })
|
|
31
|
+
.sort();
|
|
32
|
+
for (const file of goFiles) {
|
|
33
|
+
const content = fs.readFileSync(path.join(subDir, file), 'utf-8');
|
|
34
|
+
hash.update(`file:${entry.name}/${file}\n`);
|
|
35
|
+
hash.update(content);
|
|
36
|
+
}
|
|
20
37
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
// add all rule file contents in sorted order
|
|
25
|
-
const files = fs
|
|
26
|
-
.readdirSync(lintcnDir)
|
|
27
|
-
.filter((f) => {
|
|
28
|
-
return f.endsWith('.go');
|
|
29
|
-
})
|
|
30
|
-
.sort();
|
|
31
|
-
for (const file of files) {
|
|
32
|
-
const content = fs.readFileSync(path.join(lintcnDir, file), 'utf-8');
|
|
33
|
-
hash.update(`file:${file}\n`);
|
|
34
|
-
hash.update(content);
|
|
35
|
-
}
|
|
36
|
-
return hash.digest('hex').slice(0, 16);
|
|
38
|
+
const full = hash.digest('hex');
|
|
39
|
+
return { short: full.slice(0, 16), full };
|
|
37
40
|
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { addRule } from './commands/add.ts'
|
|
|
9
9
|
import { lint, buildBinary } from './commands/lint.ts'
|
|
10
10
|
import { listRules } from './commands/list.ts'
|
|
11
11
|
import { removeRule } from './commands/remove.ts'
|
|
12
|
+
import { clean } from './commands/clean.ts'
|
|
12
13
|
import { DEFAULT_TSGOLINT_VERSION } from './cache.ts'
|
|
13
14
|
|
|
14
15
|
const require = createRequire(import.meta.url)
|
|
@@ -17,11 +18,11 @@ const packageJson = require('../package.json') as { version: string }
|
|
|
17
18
|
const cli = goke('lintcn')
|
|
18
19
|
|
|
19
20
|
cli
|
|
20
|
-
.command('add <url>', 'Add a rule by URL. Fetches the
|
|
21
|
-
.example('# Add a rule
|
|
22
|
-
.example('lintcn add https://github.com/
|
|
23
|
-
.example('# Add
|
|
24
|
-
.example('lintcn add https://
|
|
21
|
+
.command('add <url>', 'Add a rule by GitHub URL. Fetches the whole folder into .lintcn/{rule}/')
|
|
22
|
+
.example('# Add a rule folder')
|
|
23
|
+
.example('lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises')
|
|
24
|
+
.example('# Add by file URL (auto-fetches the whole folder)')
|
|
25
|
+
.example('lintcn add https://github.com/oxc-project/tsgolint/blob/main/internal/rules/await_thenable/await_thenable.go')
|
|
25
26
|
.action(async (url) => {
|
|
26
27
|
await addRule(url)
|
|
27
28
|
})
|
|
@@ -77,6 +78,12 @@ cli
|
|
|
77
78
|
console.log(binaryPath)
|
|
78
79
|
})
|
|
79
80
|
|
|
81
|
+
cli
|
|
82
|
+
.command('clean', 'Remove cached tsgolint source and compiled binaries to free disk space')
|
|
83
|
+
.action(() => {
|
|
84
|
+
clean()
|
|
85
|
+
})
|
|
86
|
+
|
|
80
87
|
cli.help()
|
|
81
88
|
cli.version(packageJson.version)
|
|
82
89
|
cli.parse()
|
package/src/codegen.ts
CHANGED
|
@@ -129,10 +129,29 @@ go 1.26
|
|
|
129
129
|
fs.writeFileSync(path.join(buildDir, 'wrapper', 'main.go'), mainGo)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/** Sanitize a package name into a valid Go identifier for use as an import alias.
|
|
133
|
+
* Replaces hyphens/dots with underscores, prepends _ if starts with a digit. */
|
|
134
|
+
function toGoAlias(pkg: string): string {
|
|
135
|
+
let alias = pkg.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
136
|
+
if (/^[0-9]/.test(alias)) {
|
|
137
|
+
alias = '_' + alias
|
|
138
|
+
}
|
|
139
|
+
return alias
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Generate main.go that imports user rules and calls internal/runner.Run().
|
|
143
|
+
* Each rule subfolder is its own Go package, imported by package name. */
|
|
133
144
|
function generateMainGo(rules: RuleMetadata[]): string {
|
|
145
|
+
// Deduplicate imports by package name (in case a subfolder has multiple rules)
|
|
146
|
+
const uniquePackages = [...new Set(rules.map((r) => { return r.packageName }))]
|
|
147
|
+
const imports = uniquePackages.map((pkg) => {
|
|
148
|
+
const alias = toGoAlias(pkg)
|
|
149
|
+
return `\t${alias} "${TSGOLINT_MODULE}/lintcn-rules/${pkg}"`
|
|
150
|
+
}).join('\n')
|
|
151
|
+
|
|
134
152
|
const ruleEntries = rules.map((r) => {
|
|
135
|
-
|
|
153
|
+
const alias = toGoAlias(r.packageName)
|
|
154
|
+
return `\t\t${alias}.${r.varName},`
|
|
136
155
|
}).join('\n')
|
|
137
156
|
|
|
138
157
|
return `// Code generated by lintcn. DO NOT EDIT.
|
|
@@ -143,7 +162,7 @@ import (
|
|
|
143
162
|
|
|
144
163
|
\t"${TSGOLINT_MODULE}/internal/rule"
|
|
145
164
|
\t"${TSGOLINT_MODULE}/internal/runner"
|
|
146
|
-
|
|
165
|
+
${imports}
|
|
147
166
|
)
|
|
148
167
|
|
|
149
168
|
func main() {
|
package/src/commands/add.ts
CHANGED
|
@@ -1,69 +1,110 @@
|
|
|
1
|
-
// lintcn add <url> — fetch a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// lintcn add <url> — fetch a rule folder by URL and copy into .lintcn/{rule_name}/
|
|
2
|
+
// Supports GitHub folder URLs (/tree/) and file URLs (/blob/).
|
|
3
|
+
// For file URLs, auto-detects the parent folder and fetches all sibling files.
|
|
4
|
+
// Uses GitHub API to list folder contents.
|
|
4
5
|
|
|
5
6
|
import fs from 'node:fs'
|
|
6
7
|
import path from 'node:path'
|
|
8
|
+
import { execSync } from 'node:child_process'
|
|
7
9
|
import { getLintcnDir } from '../paths.ts'
|
|
8
10
|
import { generateEditorGoFiles } from '../codegen.ts'
|
|
9
11
|
import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from '../cache.ts'
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
interface ParsedGitHubUrl {
|
|
14
|
+
owner: string
|
|
15
|
+
repo: string
|
|
16
|
+
ref: string
|
|
17
|
+
/** Path to the directory containing the rule files */
|
|
18
|
+
dirPath: string
|
|
19
|
+
/** Set when URL points to a specific file (not a folder) */
|
|
20
|
+
fileName?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse GitHub blob/tree/raw URLs into components.
|
|
24
|
+
* Ref is assumed to be the first path component after blob/tree —
|
|
25
|
+
* branch names with slashes (e.g. feature/foo) are not supported. */
|
|
26
|
+
function parseGitHubUrl(url: string): ParsedGitHubUrl | null {
|
|
27
|
+
// GitHub blob URLs: github.com/owner/repo/blob/ref/path/to/file.go
|
|
28
|
+
let match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/)
|
|
29
|
+
if (match) {
|
|
30
|
+
const [, owner, repo, ref, filePath] = match
|
|
31
|
+
return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) }
|
|
18
32
|
}
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
// GitHub tree URLs: github.com/owner/repo/tree/ref/path/to/folder
|
|
35
|
+
match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)$/)
|
|
36
|
+
if (match) {
|
|
37
|
+
const [, owner, repo, ref, dirPath] = match
|
|
38
|
+
return { owner, repo, ref, dirPath }
|
|
39
|
+
}
|
|
23
40
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
41
|
+
// raw.githubusercontent.com URLs
|
|
42
|
+
match = url.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/)
|
|
43
|
+
if (match) {
|
|
44
|
+
const [, owner, repo, ref, filePath] = match
|
|
45
|
+
return { owner, repo, ref, dirPath: path.posix.dirname(filePath), fileName: path.posix.basename(filePath) }
|
|
28
46
|
}
|
|
29
|
-
const [, owner, repo] = repoMatch
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
// We can pass refAndPath directly since GitHub resolves it.
|
|
33
|
-
return `https://raw.githubusercontent.com/${owner}/${repo}/${refAndPath}`
|
|
48
|
+
return null
|
|
34
49
|
}
|
|
35
50
|
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
interface GitHubContentItem {
|
|
52
|
+
name: string
|
|
53
|
+
download_url: string | null
|
|
54
|
+
type: 'file' | 'dir'
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
57
|
+
/** Get a GitHub auth token from gh CLI, GITHUB_TOKEN env, or return undefined. */
|
|
58
|
+
function getGitHubToken(): string | undefined {
|
|
59
|
+
if (process.env.GITHUB_TOKEN) {
|
|
60
|
+
return process.env.GITHUB_TOKEN
|
|
44
61
|
}
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function tryFetchFile(url: string): Promise<string | null> {
|
|
62
|
+
// Try gh CLI token (synchronous to keep it simple)
|
|
49
63
|
try {
|
|
50
|
-
return
|
|
64
|
+
return execSync('gh auth token', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || undefined
|
|
51
65
|
} catch {
|
|
52
|
-
return
|
|
66
|
+
return undefined
|
|
53
67
|
}
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
/** List files in a GitHub directory via the Contents API. */
|
|
71
|
+
async function listGitHubFolder(owner: string, repo: string, dirPath: string, ref: string): Promise<GitHubContentItem[]> {
|
|
72
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}?ref=${ref}`
|
|
73
|
+
const headers: Record<string, string> = {
|
|
74
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
75
|
+
'User-Agent': 'lintcn',
|
|
76
|
+
}
|
|
77
|
+
const token = getGitHubToken()
|
|
78
|
+
if (token) {
|
|
79
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
80
|
+
}
|
|
81
|
+
const response = await fetch(apiUrl, { headers })
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n ${apiUrl}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const data = await response.json()
|
|
88
|
+
if (!Array.isArray(data)) {
|
|
89
|
+
throw new Error(`Expected a directory listing from GitHub API but got a single file.\n ${apiUrl}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return data as GitHubContentItem[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function fetchFile(url: string): Promise<string> {
|
|
96
|
+
const response = await fetch(url)
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
|
99
|
+
}
|
|
100
|
+
return response.text()
|
|
60
101
|
}
|
|
61
102
|
|
|
62
103
|
function ensureSourceComment(content: string, sourceUrl: string): string {
|
|
63
104
|
if (content.includes('// lintcn:source')) {
|
|
64
105
|
return content
|
|
65
106
|
}
|
|
66
|
-
// Insert source comment after
|
|
107
|
+
// Insert source comment after any existing lintcn: comment block, or at the very top
|
|
67
108
|
const lines = content.split('\n')
|
|
68
109
|
let insertIndex = 0
|
|
69
110
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -78,59 +119,76 @@ function ensureSourceComment(content: string, sourceUrl: string): string {
|
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
export async function addRule(url: string): Promise<void> {
|
|
81
|
-
const
|
|
122
|
+
const parsed = parseGitHubUrl(url)
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
'Only GitHub URLs are supported. Pass a /blob/ (file) or /tree/ (folder) URL.\n' +
|
|
126
|
+
'Example: lintcn add https://github.com/oxc-project/tsgolint/tree/main/internal/rules/no_floating_promises',
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { owner, repo, ref, dirPath } = parsed
|
|
131
|
+
const folderName = path.posix.basename(dirPath)
|
|
132
|
+
|
|
133
|
+
console.log(`Fetching ${owner}/${repo}/${dirPath}...`)
|
|
134
|
+
const items = await listGitHubFolder(owner, repo, dirPath, ref)
|
|
82
135
|
|
|
83
|
-
|
|
84
|
-
const
|
|
136
|
+
// Filter for .go and .json files
|
|
137
|
+
const filesToFetch = items.filter((item) => {
|
|
138
|
+
return item.type === 'file' && item.download_url && (item.name.endsWith('.go') || item.name.endsWith('.json'))
|
|
139
|
+
})
|
|
85
140
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
console.warn('Warning: no rule.Rule reference found in this file. Are you sure this is a tsgolint rule?')
|
|
141
|
+
if (filesToFetch.length === 0) {
|
|
142
|
+
throw new Error(`No .go files found in ${dirPath}. Is this a rule folder?`)
|
|
89
143
|
}
|
|
90
144
|
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
145
|
+
// Warn if this doesn't look like a single-rule folder (too many main .go files)
|
|
146
|
+
const mainGoFiles = filesToFetch.filter((f) => {
|
|
147
|
+
return f.name.endsWith('.go') && !f.name.endsWith('_test.go') && f.name !== 'options.go'
|
|
148
|
+
})
|
|
149
|
+
if (mainGoFiles.length > 3) {
|
|
150
|
+
console.warn(
|
|
151
|
+
`Warning: folder has ${mainGoFiles.length} non-test .go files. ` +
|
|
152
|
+
`This may be a directory of multiple rules — consider using a more specific URL.`,
|
|
153
|
+
)
|
|
96
154
|
}
|
|
97
155
|
|
|
98
156
|
const lintcnDir = getLintcnDir()
|
|
99
|
-
|
|
157
|
+
const ruleDir = path.join(lintcnDir, folderName)
|
|
100
158
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
console.log(`Overwriting existing ${
|
|
159
|
+
// Clean existing rule folder if it exists
|
|
160
|
+
if (fs.existsSync(ruleDir)) {
|
|
161
|
+
fs.rmSync(ruleDir, { recursive: true })
|
|
162
|
+
console.log(`Overwriting existing ${folderName}/`)
|
|
105
163
|
}
|
|
106
164
|
|
|
107
|
-
|
|
108
|
-
processed = ensureSourceComment(processed, url)
|
|
109
|
-
fs.writeFileSync(filePath, processed)
|
|
110
|
-
console.log(`Added ${fileName}`)
|
|
165
|
+
fs.mkdirSync(ruleDir, { recursive: true })
|
|
111
166
|
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
167
|
+
// Fetch and write all files
|
|
168
|
+
for (const item of filesToFetch) {
|
|
169
|
+
let content = await fetchFile(item.download_url!)
|
|
170
|
+
|
|
171
|
+
// Add lintcn:source comment to the main rule file (same name as folder)
|
|
172
|
+
if (item.name === `${folderName}.go`) {
|
|
173
|
+
content = ensureSourceComment(content, url)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fs.writeFileSync(path.join(ruleDir, item.name), content)
|
|
177
|
+
console.log(` ${item.name}`)
|
|
120
178
|
}
|
|
121
179
|
|
|
122
|
-
|
|
180
|
+
console.log(`Added ${folderName}/ (${filesToFetch.length} files)`)
|
|
181
|
+
|
|
182
|
+
// Ensure tsgolint source is available
|
|
123
183
|
const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION)
|
|
124
184
|
|
|
125
|
-
//
|
|
126
|
-
// Use lstatSync to detect broken symlinks (existsSync returns false for broken links)
|
|
185
|
+
// Create/refresh .tsgolint symlink for gopls
|
|
127
186
|
const tsgolintLink = path.join(lintcnDir, '.tsgolint')
|
|
128
187
|
try {
|
|
129
188
|
fs.lstatSync(tsgolintLink)
|
|
130
|
-
// exists (possibly broken) — remove and recreate
|
|
131
189
|
fs.rmSync(tsgolintLink, { force: true })
|
|
132
190
|
} catch {
|
|
133
|
-
// doesn't exist
|
|
191
|
+
// doesn't exist
|
|
134
192
|
}
|
|
135
193
|
fs.symlinkSync(tsgolintDir, tsgolintLink)
|
|
136
194
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// lintcn clean — remove cached tsgolint source and compiled binaries.
|
|
2
|
+
// Frees disk space from old versions that accumulate over time.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import { getCacheDir } from '../cache.ts'
|
|
6
|
+
|
|
7
|
+
export function clean(): void {
|
|
8
|
+
const cacheDir = getCacheDir()
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(cacheDir)) {
|
|
11
|
+
console.log('No cache to clean')
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stats = getCacheStats(cacheDir)
|
|
16
|
+
fs.rmSync(cacheDir, { recursive: true })
|
|
17
|
+
console.log(`Removed ${cacheDir} (${formatBytes(stats.totalBytes)})`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getCacheStats(dir: string): { totalBytes: number } {
|
|
21
|
+
let totalBytes = 0
|
|
22
|
+
const walk = (d: string): void => {
|
|
23
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
24
|
+
const fullPath = `${d}/${entry.name}`
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
walk(fullPath)
|
|
27
|
+
} else {
|
|
28
|
+
totalBytes += fs.statSync(fullPath).size
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
walk(dir)
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore errors during stat
|
|
36
|
+
}
|
|
37
|
+
return { totalBytes }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatBytes(bytes: number): string {
|
|
41
|
+
if (bytes < 1024) {
|
|
42
|
+
return `${bytes}B`
|
|
43
|
+
}
|
|
44
|
+
if (bytes < 1024 * 1024) {
|
|
45
|
+
return `${(bytes / 1024).toFixed(0)}KB`
|
|
46
|
+
}
|
|
47
|
+
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`
|
|
48
|
+
}
|