lintcn 0.0.1 → 0.2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +157 -5
  4. package/dist/cache.d.ts +9 -0
  5. package/dist/cache.d.ts.map +1 -0
  6. package/dist/cache.js +88 -0
  7. package/dist/cli.js +71 -3
  8. package/dist/codegen.d.ts +18 -0
  9. package/dist/codegen.d.ts.map +1 -0
  10. package/dist/codegen.js +607 -0
  11. package/dist/commands/add.d.ts +2 -0
  12. package/dist/commands/add.d.ts.map +1 -0
  13. package/dist/commands/add.js +101 -0
  14. package/dist/commands/lint.d.ts +10 -0
  15. package/dist/commands/lint.d.ts.map +1 -0
  16. package/dist/commands/lint.js +78 -0
  17. package/dist/commands/list.d.ts +2 -0
  18. package/dist/commands/list.d.ts.map +1 -0
  19. package/dist/commands/list.js +24 -0
  20. package/dist/commands/remove.d.ts +2 -0
  21. package/dist/commands/remove.d.ts.map +1 -0
  22. package/dist/commands/remove.js +31 -0
  23. package/dist/discover.d.ts +16 -0
  24. package/dist/discover.d.ts.map +1 -0
  25. package/dist/discover.js +44 -0
  26. package/dist/exec.d.ts +10 -0
  27. package/dist/exec.d.ts.map +1 -0
  28. package/dist/exec.js +34 -0
  29. package/dist/hash.d.ts +5 -0
  30. package/dist/hash.d.ts.map +1 -0
  31. package/dist/hash.js +33 -0
  32. package/dist/index.d.ts +7 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +6 -1
  35. package/dist/paths.d.ts +2 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +5 -0
  38. package/package.json +12 -9
  39. package/src/cache.ts +106 -0
  40. package/src/cli.ts +80 -2
  41. package/src/codegen.ts +640 -0
  42. package/src/commands/add.ts +118 -0
  43. package/src/commands/lint.ts +110 -0
  44. package/src/commands/list.ts +33 -0
  45. package/src/commands/remove.ts +41 -0
  46. package/src/discover.ts +69 -0
  47. package/src/exec.ts +50 -0
  48. package/src/hash.ts +45 -0
  49. package/src/index.ts +7 -1
  50. package/src/paths.ts +7 -0
@@ -0,0 +1,118 @@
1
+ // lintcn add <url> — fetch a .go rule file by URL and copy into .lintcn/
2
+ // Also tries to fetch matching _test.go file from the same directory.
3
+ // Normalizes GitHub blob URLs to raw URLs automatically.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { getLintcnDir } from '../paths.ts'
8
+ import { generateEditorGoFiles } from '../codegen.ts'
9
+ import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from '../cache.ts'
10
+
11
+ function normalizeGithubUrl(url: string): string {
12
+ // Convert github.com/user/repo/blob/branch/path to raw.githubusercontent.com
13
+ const blobMatch = url.match(
14
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
15
+ )
16
+ if (blobMatch) {
17
+ const [, owner, repo, branch, filePath] = blobMatch
18
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`
19
+ }
20
+ return url
21
+ }
22
+
23
+ function deriveTestUrl(rawUrl: string): string {
24
+ return rawUrl.replace(/\.go$/, '_test.go')
25
+ }
26
+
27
+ async function fetchFile(url: string): Promise<string | null> {
28
+ try {
29
+ const response = await fetch(url)
30
+ if (!response.ok) {
31
+ return null
32
+ }
33
+ return await response.text()
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ function rewritePackageName(content: string): string {
40
+ // Rewrite first package declaration to package lintcn
41
+ return content.replace(/^package\s+\w+/m, 'package lintcn')
42
+ }
43
+
44
+ function ensureSourceComment(content: string, sourceUrl: string): string {
45
+ if (content.includes('// lintcn:source')) {
46
+ return content
47
+ }
48
+ // Insert source comment after the first lintcn: comment block, or at the very top
49
+ const lines = content.split('\n')
50
+ let insertIndex = 0
51
+ for (let i = 0; i < lines.length; i++) {
52
+ if (lines[i].startsWith('// lintcn:')) {
53
+ insertIndex = i + 1
54
+ } else if (insertIndex > 0) {
55
+ break
56
+ }
57
+ }
58
+ lines.splice(insertIndex, 0, `// lintcn:source ${sourceUrl}`)
59
+ return lines.join('\n')
60
+ }
61
+
62
+ export async function addRule(url: string): Promise<void> {
63
+ const rawUrl = normalizeGithubUrl(url)
64
+
65
+ console.log(`Fetching ${rawUrl}...`)
66
+ const content = await fetchFile(rawUrl)
67
+ if (!content) {
68
+ throw new Error(`Could not fetch rule from ${rawUrl}`)
69
+ }
70
+
71
+ // validate it looks like a Go file with a rule
72
+ if (!content.includes('rule.Rule')) {
73
+ console.warn('Warning: no rule.Rule reference found in this file. Are you sure this is a tsgolint rule?')
74
+ }
75
+
76
+ // derive filename from URL
77
+ const urlPath = new URL(rawUrl).pathname
78
+ const fileName = path.basename(urlPath)
79
+ if (!fileName.endsWith('.go')) {
80
+ throw new Error(`URL must point to a .go file, got: ${fileName}`)
81
+ }
82
+
83
+ const lintcnDir = getLintcnDir()
84
+ fs.mkdirSync(lintcnDir, { recursive: true })
85
+
86
+ // write the rule file
87
+ const filePath = path.join(lintcnDir, fileName)
88
+ if (fs.existsSync(filePath)) {
89
+ console.log(`Overwriting existing ${fileName}`)
90
+ }
91
+
92
+ let processed = rewritePackageName(content)
93
+ processed = ensureSourceComment(processed, url)
94
+ fs.writeFileSync(filePath, processed)
95
+ console.log(`Added ${fileName}`)
96
+
97
+ // try to fetch matching test file
98
+ const testUrl = deriveTestUrl(rawUrl)
99
+ const testContent = await fetchFile(testUrl)
100
+ if (testContent) {
101
+ const testFileName = fileName.replace(/\.go$/, '_test.go')
102
+ const testProcessed = rewritePackageName(testContent)
103
+ fs.writeFileSync(path.join(lintcnDir, testFileName), testProcessed)
104
+ console.log(`Added ${testFileName}`)
105
+ }
106
+
107
+ // ensure .tsgolint source is available and generate editor support files
108
+ const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION)
109
+
110
+ // create .tsgolint symlink inside .lintcn for gopls
111
+ const tsgolintLink = path.join(lintcnDir, '.tsgolint')
112
+ if (!fs.existsSync(tsgolintLink)) {
113
+ fs.symlinkSync(tsgolintDir, tsgolintLink)
114
+ }
115
+
116
+ generateEditorGoFiles(lintcnDir)
117
+ console.log('Editor support files generated (go.work, go.mod)')
118
+ }
@@ -0,0 +1,110 @@
1
+ // lintcn lint — build a custom tsgolint binary and run it against the project.
2
+ // Handles Go workspace generation, compilation with caching, and execution.
3
+
4
+ import fs from 'node:fs'
5
+ import { spawn } from 'node:child_process'
6
+ import { getLintcnDir } from '../paths.ts'
7
+ import { discoverRules } from '../discover.ts'
8
+ import { generateBuildWorkspace } from '../codegen.ts'
9
+ import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
10
+ import { computeContentHash } from '../hash.ts'
11
+ import { execAsync } from '../exec.ts'
12
+
13
+ async function checkGoInstalled(): Promise<void> {
14
+ try {
15
+ await execAsync('go', ['version'])
16
+ } catch {
17
+ throw new Error(
18
+ 'Go 1.26+ is required to build rules.\n' +
19
+ 'Install from https://go.dev/dl/',
20
+ )
21
+ }
22
+ }
23
+
24
+ export async function buildBinary({
25
+ rebuild,
26
+ tsgolintVersion,
27
+ }: {
28
+ rebuild: boolean
29
+ tsgolintVersion: string
30
+ }): Promise<string> {
31
+ await checkGoInstalled()
32
+
33
+ const lintcnDir = getLintcnDir()
34
+ if (!fs.existsSync(lintcnDir)) {
35
+ throw new Error('No .lintcn/ directory found. Run `lintcn add <url>` first.')
36
+ }
37
+
38
+ const rules = discoverRules(lintcnDir)
39
+ if (rules.length === 0) {
40
+ throw new Error('No rules found in .lintcn/. Run `lintcn add <url>` to add rules.')
41
+ }
42
+
43
+ console.log(`Found ${rules.length} custom rule${rules.length === 1 ? '' : 's'} (tsgolint ${tsgolintVersion})`)
44
+
45
+ // ensure tsgolint source
46
+ const tsgolintDir = await ensureTsgolintSource(tsgolintVersion)
47
+
48
+ // compute content hash
49
+ const contentHash = await computeContentHash({
50
+ lintcnDir,
51
+ tsgolintVersion,
52
+ })
53
+
54
+ // check cache
55
+ if (!rebuild && cachedBinaryExists(contentHash)) {
56
+ console.log('Using cached binary')
57
+ return getBinaryPath(contentHash)
58
+ }
59
+
60
+ // generate build workspace
61
+ const buildDir = getBuildDir()
62
+ console.log('Generating build workspace...')
63
+ generateBuildWorkspace({
64
+ buildDir,
65
+ tsgolintDir,
66
+ lintcnDir,
67
+ rules,
68
+ })
69
+
70
+ // compile
71
+ const binDir = getBinDir()
72
+ fs.mkdirSync(binDir, { recursive: true })
73
+ const binaryPath = getBinaryPath(contentHash)
74
+
75
+ console.log('Compiling custom tsgolint binary...')
76
+ await execAsync('go', ['build', '-o', binaryPath, './wrapper'], {
77
+ cwd: buildDir,
78
+ })
79
+
80
+ console.log('Build complete')
81
+ return binaryPath
82
+ }
83
+
84
+ export async function lint({
85
+ rebuild,
86
+ tsgolintVersion,
87
+ passthroughArgs,
88
+ }: {
89
+ rebuild: boolean
90
+ tsgolintVersion: string
91
+ passthroughArgs: string[]
92
+ }): Promise<number> {
93
+ const binaryPath = await buildBinary({ rebuild, tsgolintVersion })
94
+
95
+ // run the binary with passthrough args, inheriting stdio
96
+ return new Promise((resolve) => {
97
+ const proc = spawn(binaryPath, passthroughArgs, {
98
+ stdio: 'inherit',
99
+ })
100
+
101
+ proc.on('error', (err) => {
102
+ console.error(`Failed to run binary: ${err.message}`)
103
+ resolve(1)
104
+ })
105
+
106
+ proc.on('close', (code) => {
107
+ resolve(code ?? 1)
108
+ })
109
+ })
110
+ }
@@ -0,0 +1,33 @@
1
+ // lintcn list — list installed rules with metadata from .lintcn/
2
+
3
+ import fs from 'node:fs'
4
+ import { getLintcnDir } from '../paths.ts'
5
+ import { discoverRules } from '../discover.ts'
6
+
7
+ export function listRules(): void {
8
+ const lintcnDir = getLintcnDir()
9
+
10
+ if (!fs.existsSync(lintcnDir)) {
11
+ console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.')
12
+ return
13
+ }
14
+
15
+ const rules = discoverRules(lintcnDir)
16
+
17
+ if (rules.length === 0) {
18
+ console.log('No rules installed. Run `lintcn add <url>` to add rules.')
19
+ return
20
+ }
21
+
22
+ console.log('Installed rules:\n')
23
+
24
+ const maxNameLen = Math.max(...rules.map((r) => { return r.name.length }))
25
+
26
+ for (const rule of rules) {
27
+ const name = rule.name.padEnd(maxNameLen + 2)
28
+ const desc = rule.description || '(no description)'
29
+ console.log(` ${name}${desc}`)
30
+ }
31
+
32
+ console.log(`\n${rules.length} rule${rules.length === 1 ? '' : 's'} installed`)
33
+ }
@@ -0,0 +1,41 @@
1
+ // lintcn remove <name> — delete a rule and its test file from .lintcn/
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { getLintcnDir } from '../paths.ts'
6
+ import { discoverRules } from '../discover.ts'
7
+
8
+ export function removeRule(name: string): void {
9
+ const lintcnDir = getLintcnDir()
10
+
11
+ if (!fs.existsSync(lintcnDir)) {
12
+ throw new Error('No .lintcn/ directory found.')
13
+ }
14
+
15
+ // match by lintcn:name metadata or by filename
16
+ const rules = discoverRules(lintcnDir)
17
+ const normalizedName = name.replace(/-/g, '_')
18
+
19
+ const match = rules.find((r) => {
20
+ return r.name === name || r.fileName.replace(/\.go$/, '') === normalizedName
21
+ })
22
+
23
+ if (!match) {
24
+ throw new Error(
25
+ `Rule "${name}" not found. Run \`lintcn list\` to see installed rules.`,
26
+ )
27
+ }
28
+
29
+ // delete rule file
30
+ const rulePath = path.join(lintcnDir, match.fileName)
31
+ fs.rmSync(rulePath)
32
+ console.log(`Removed ${match.fileName}`)
33
+
34
+ // delete test file if exists
35
+ const testFileName = match.fileName.replace(/\.go$/, '_test.go')
36
+ const testPath = path.join(lintcnDir, testFileName)
37
+ if (fs.existsSync(testPath)) {
38
+ fs.rmSync(testPath)
39
+ console.log(`Removed ${testFileName}`)
40
+ }
41
+ }
@@ -0,0 +1,69 @@
1
+ // Scan .lintcn/*.go files for rule.Rule variables and lintcn: metadata comments.
2
+ // Returns structured info about each discovered rule for codegen and list display.
3
+
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ export interface RuleMetadata {
8
+ /** kebab-case rule name from // lintcn:name or derived from filename */
9
+ name: string
10
+ /** one-line description from // lintcn:description */
11
+ description: string
12
+ /** original source URL from // lintcn:source */
13
+ source: string
14
+ /** exported Go variable name like NoFloatingPromisesRule */
15
+ varName: string
16
+ /** filename relative to .lintcn/ */
17
+ fileName: string
18
+ }
19
+
20
+ const RULE_VAR_RE = /^var\s+(\w+)\s*=\s*rule\.Rule\s*\{/m
21
+ const METADATA_RE = /^\/\/\s*lintcn:(\w+)\s+(.+)$/gm
22
+
23
+ export function parseMetadata(content: string): Record<string, string> {
24
+ const meta: Record<string, string> = {}
25
+ for (const match of content.matchAll(METADATA_RE)) {
26
+ meta[match[1]] = match[2].trim()
27
+ }
28
+ return meta
29
+ }
30
+
31
+ export function parseRuleVar(content: string): string | undefined {
32
+ const match = content.match(RULE_VAR_RE)
33
+ return match?.[1]
34
+ }
35
+
36
+ export function discoverRules(lintcnDir: string): RuleMetadata[] {
37
+ if (!fs.existsSync(lintcnDir)) {
38
+ return []
39
+ }
40
+
41
+ const files = fs.readdirSync(lintcnDir).filter((f) => {
42
+ return f.endsWith('.go') && !f.endsWith('_test.go')
43
+ })
44
+
45
+ const rules: RuleMetadata[] = []
46
+
47
+ for (const fileName of files) {
48
+ const filePath = path.join(lintcnDir, fileName)
49
+ const content = fs.readFileSync(filePath, 'utf-8')
50
+
51
+ const varName = parseRuleVar(content)
52
+ if (!varName) {
53
+ continue
54
+ }
55
+
56
+ const meta = parseMetadata(content)
57
+ const baseName = fileName.replace(/\.go$/, '')
58
+
59
+ rules.push({
60
+ name: meta.name || baseName.replace(/_/g, '-'),
61
+ description: meta.description || '',
62
+ source: meta.source || '',
63
+ varName,
64
+ fileName,
65
+ })
66
+ }
67
+
68
+ return rules
69
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Async process execution utility using spawn.
2
+ // Returns stdout/stderr as strings, rejects on non-zero exit code.
3
+
4
+ import { spawn } from 'node:child_process'
5
+
6
+ export interface ExecResult {
7
+ stdout: string
8
+ stderr: string
9
+ exitCode: number
10
+ }
11
+
12
+ export function execAsync(
13
+ command: string,
14
+ args: string[],
15
+ options?: { cwd?: string; stdio?: 'pipe' | 'inherit' },
16
+ ): Promise<ExecResult> {
17
+ return new Promise((resolve, reject) => {
18
+ const proc = spawn(command, args, {
19
+ cwd: options?.cwd,
20
+ stdio: options?.stdio === 'inherit' ? 'inherit' : 'pipe',
21
+ })
22
+
23
+ let stdout = ''
24
+ let stderr = ''
25
+
26
+ if (proc.stdout) {
27
+ proc.stdout.on('data', (data: Buffer) => {
28
+ stdout += data.toString()
29
+ })
30
+ }
31
+ if (proc.stderr) {
32
+ proc.stderr.on('data', (data: Buffer) => {
33
+ stderr += data.toString()
34
+ })
35
+ }
36
+
37
+ proc.on('error', (err) => {
38
+ reject(new Error(`Failed to execute ${command}: ${err.message}`, { cause: err }))
39
+ })
40
+
41
+ proc.on('close', (code) => {
42
+ const exitCode = code ?? 1
43
+ if (exitCode !== 0 && options?.stdio !== 'inherit') {
44
+ reject(new Error(`${command} exited with code ${exitCode}\n${stderr}`))
45
+ return
46
+ }
47
+ resolve({ stdout, stderr, exitCode })
48
+ })
49
+ })
50
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,45 @@
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.
4
+
5
+ import crypto from 'node:crypto'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { execAsync } from './exec.ts'
9
+
10
+ export async function computeContentHash({
11
+ lintcnDir,
12
+ tsgolintVersion,
13
+ }: {
14
+ lintcnDir: string
15
+ tsgolintVersion: string
16
+ }): Promise<string> {
17
+ const hash = crypto.createHash('sha256')
18
+
19
+ hash.update(`tsgolint:${tsgolintVersion}\n`)
20
+ hash.update(`platform:${process.platform}-${process.arch}\n`)
21
+
22
+ // add Go version
23
+ try {
24
+ const { stdout } = await execAsync('go', ['version'])
25
+ hash.update(`go:${stdout.trim()}\n`)
26
+ } catch {
27
+ hash.update('go:unknown\n')
28
+ }
29
+
30
+ // add all rule file contents in sorted order
31
+ const files = fs
32
+ .readdirSync(lintcnDir)
33
+ .filter((f) => {
34
+ return f.endsWith('.go')
35
+ })
36
+ .sort()
37
+
38
+ for (const file of files) {
39
+ const content = fs.readFileSync(path.join(lintcnDir, file), 'utf-8')
40
+ hash.update(`file:${file}\n`)
41
+ hash.update(content)
42
+ }
43
+
44
+ return hash.digest('hex').slice(0, 16)
45
+ }
package/src/index.ts CHANGED
@@ -1 +1,7 @@
1
- export const version = '0.0.1'
1
+ export { discoverRules, parseMetadata, parseRuleVar } from './discover.ts'
2
+ export type { RuleMetadata } from './discover.ts'
3
+ export { addRule } from './commands/add.ts'
4
+ export { lint, buildBinary } from './commands/lint.ts'
5
+ export { listRules } from './commands/list.ts'
6
+ export { removeRule } from './commands/remove.ts'
7
+ export { DEFAULT_TSGOLINT_VERSION } from './cache.ts'
package/src/paths.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Resolve the .lintcn/ directory path relative to cwd.
2
+
3
+ import path from 'node:path'
4
+
5
+ export function getLintcnDir(): string {
6
+ return path.resolve(process.cwd(), '.lintcn')
7
+ }