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.
@@ -5,7 +5,7 @@ import fs from 'node:fs'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { requireLintcnDir } from '../paths.ts'
7
7
  import { discoverRules } from '../discover.ts'
8
- import { generateBuildWorkspace } from '../codegen.ts'
8
+ import { generateBuildWorkspace, generateEditorGoFiles } from '../codegen.ts'
9
9
  import { ensureTsgolintSource, validateVersion, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from '../cache.ts'
10
10
  import { computeContentHash } from '../hash.ts'
11
11
  import { execAsync } from '../exec.ts'
@@ -44,7 +44,7 @@ export async function buildBinary({
44
44
  const tsgolintDir = await ensureTsgolintSource(tsgolintVersion)
45
45
 
46
46
  // compute content hash
47
- const contentHash = await computeContentHash({
47
+ const { short: contentHash } = await computeContentHash({
48
48
  lintcnDir,
49
49
  tsgolintVersion,
50
50
  })
@@ -55,6 +55,9 @@ export async function buildBinary({
55
55
  return getBinaryPath(contentHash)
56
56
  }
57
57
 
58
+ // ensure .lintcn/go.mod exists (gitignored, needed by the build workspace symlink)
59
+ generateEditorGoFiles(lintcnDir)
60
+
58
61
  // generate build workspace (per-hash dir to avoid races between concurrent processes)
59
62
  const buildDir = getBuildDir(contentHash)
60
63
  console.log('Generating build workspace...')
@@ -70,10 +73,23 @@ export async function buildBinary({
70
73
  fs.mkdirSync(binDir, { recursive: true })
71
74
  const binaryPath = getBinaryPath(contentHash)
72
75
 
73
- console.log('Compiling custom tsgolint binary...')
74
- await execAsync('go', ['build', '-o', binaryPath, './wrapper'], {
76
+ // Check if any lintcn binary has been built before — if not, this is a cold
77
+ // build that compiles the full tsgolint + typescript-go dependency tree.
78
+ const existingBins = fs.existsSync(binDir) ? fs.readdirSync(binDir) : []
79
+ if (existingBins.length === 0) {
80
+ console.log('Compiling custom tsgolint binary (first build — may take 30s+ to compile dependencies)...')
81
+ console.log('Subsequent builds will be fast (~1s). In CI, cache ~/.cache/lintcn/ and GOCACHE (run `go env GOCACHE`).')
82
+ } else {
83
+ console.log('Compiling custom tsgolint binary...')
84
+ }
85
+
86
+ const { exitCode: buildExitCode } = await execAsync('go', ['build', '-trimpath', '-o', binaryPath, './wrapper'], {
75
87
  cwd: buildDir,
88
+ stdio: 'inherit',
76
89
  })
90
+ if (buildExitCode !== 0) {
91
+ throw new Error(`Go compilation failed (exit code ${buildExitCode})`)
92
+ }
77
93
 
78
94
  console.log('Build complete')
79
95
  return binaryPath
@@ -1,4 +1,4 @@
1
- // lintcn remove <name> — delete a rule and its test file from .lintcn/
1
+ // lintcn remove <name> — delete a rule subfolder from .lintcn/
2
2
 
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
@@ -7,13 +7,11 @@ import { discoverRules } from '../discover.ts'
7
7
 
8
8
  export function removeRule(name: string): void {
9
9
  const lintcnDir = requireLintcnDir()
10
-
11
- // match by lintcn:name metadata or by filename
12
10
  const rules = discoverRules(lintcnDir)
13
11
  const normalizedName = name.replace(/-/g, '_')
14
12
 
15
13
  const match = rules.find((r) => {
16
- return r.name === name || r.fileName.replace(/\.go$/, '') === normalizedName
14
+ return r.name === name || r.packageName === normalizedName
17
15
  })
18
16
 
19
17
  if (!match) {
@@ -22,16 +20,8 @@ export function removeRule(name: string): void {
22
20
  )
23
21
  }
24
22
 
25
- // delete rule file
26
- const rulePath = path.join(lintcnDir, match.fileName)
27
- fs.rmSync(rulePath)
28
- console.log(`Removed ${match.fileName}`)
29
-
30
- // delete test file if exists
31
- const testFileName = match.fileName.replace(/\.go$/, '_test.go')
32
- const testPath = path.join(lintcnDir, testFileName)
33
- if (fs.existsSync(testPath)) {
34
- fs.rmSync(testPath)
35
- console.log(`Removed ${testFileName}`)
36
- }
23
+ // Remove the entire subfolder
24
+ const ruleDir = path.join(lintcnDir, match.packageName)
25
+ fs.rmSync(ruleDir, { recursive: true })
26
+ console.log(`Removed ${match.packageName}/`)
37
27
  }
package/src/discover.ts CHANGED
@@ -1,11 +1,12 @@
1
- // Scan .lintcn/*.go files for rule.Rule variables and lintcn: metadata comments.
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
 
4
5
  import fs from 'node:fs'
5
6
  import path from 'node:path'
6
7
 
7
8
  export interface RuleMetadata {
8
- /** kebab-case rule name from // lintcn:name or derived from filename */
9
+ /** kebab-case rule name from // lintcn:name or derived from folder name */
9
10
  name: string
10
11
  /** one-line description from // lintcn:description */
11
12
  description: string
@@ -13,8 +14,8 @@ export interface RuleMetadata {
13
14
  source: string
14
15
  /** exported Go variable name like NoFloatingPromisesRule */
15
16
  varName: string
16
- /** filename relative to .lintcn/ */
17
- fileName: string
17
+ /** Go package name (= subfolder name, e.g. no_floating_promises) */
18
+ packageName: string
18
19
  }
19
20
 
20
21
  // Matches `var XxxRule = rule.Rule{` with optional leading whitespace
@@ -40,38 +41,46 @@ export function discoverRules(lintcnDir: string): RuleMetadata[] {
40
41
  return []
41
42
  }
42
43
 
43
- const files = fs.readdirSync(lintcnDir).filter((f) => {
44
- return f.endsWith('.go') && !f.endsWith('_test.go')
44
+ const rules: RuleMetadata[] = []
45
+ const entries = fs.readdirSync(lintcnDir, { withFileTypes: true })
46
+
47
+ // Warn about flat .go files that should be in subfolders
48
+ const flatGoFiles = entries.filter((e) => {
49
+ return e.isFile() && e.name.endsWith('.go')
45
50
  })
51
+ for (const file of flatGoFiles) {
52
+ const baseName = file.name.replace(/(_test)?\.go$/, '')
53
+ console.error(
54
+ `Error: ${file.name} is a flat file in .lintcn/ — rules must be in subfolders.\n` +
55
+ ` Move it to .lintcn/${baseName}/${file.name} and change the package to "${baseName}".\n`,
56
+ )
57
+ }
46
58
 
47
- const rules: RuleMetadata[] = []
59
+ for (const entry of entries) {
60
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
48
61
 
49
- for (const fileName of files) {
50
- const filePath = path.join(lintcnDir, fileName)
51
- const content = fs.readFileSync(filePath, 'utf-8')
62
+ const subDir = path.join(lintcnDir, entry.name)
63
+ const goFiles = fs.readdirSync(subDir).filter((f) => {
64
+ return f.endsWith('.go') && !f.endsWith('_test.go')
65
+ })
52
66
 
53
- const varName = parseRuleVar(content)
54
- if (!varName) {
55
- // warn if file contains rule.Rule but we couldn't parse the var name
56
- if (content.includes('rule.Rule')) {
57
- console.warn(
58
- `Warning: ${fileName} contains rule.Rule but no exported var was found. ` +
59
- `Expected pattern: var XxxRule = rule.Rule{`,
60
- )
61
- }
62
- continue
63
- }
67
+ for (const fileName of goFiles) {
68
+ const filePath = path.join(subDir, fileName)
69
+ const content = fs.readFileSync(filePath, 'utf-8')
64
70
 
65
- const meta = parseMetadata(content)
66
- const baseName = fileName.replace(/\.go$/, '')
71
+ const varName = parseRuleVar(content)
72
+ if (!varName) continue
67
73
 
68
- rules.push({
69
- name: meta.name || baseName.replace(/_/g, '-'),
70
- description: meta.description || '',
71
- source: meta.source || '',
72
- varName,
73
- fileName,
74
- })
74
+ const meta = parseMetadata(content)
75
+
76
+ rules.push({
77
+ name: meta.name || entry.name.replace(/_/g, '-'),
78
+ description: meta.description || '',
79
+ source: meta.source || '',
80
+ varName,
81
+ packageName: entry.name,
82
+ })
83
+ }
75
84
  }
76
85
 
77
86
  return rules
package/src/hash.ts CHANGED
@@ -1,50 +1,56 @@
1
- // Content hash for binary caching.
2
- // Combines cache schema version, tsgolint version, rule file contents,
3
- // Go version, and platform into a single SHA-256 hash.
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
 
7
12
  import crypto from 'node:crypto'
8
13
  import fs from 'node:fs'
9
14
  import path from 'node:path'
10
- import { execAsync } from './exec.ts'
11
15
 
12
- const CACHE_SCHEMA_VERSION = '2'
16
+ const CACHE_SCHEMA_VERSION = '4'
13
17
 
18
+ /** Compute a deterministic content hash for binary caching.
19
+ * Returns: { short: "a1b2c3d4..." (16 hex), full: "a1b2c3d4..." (64 hex) }
20
+ * The short hash is used for local cache paths, the full hash for remote cache keys. */
14
21
  export async function computeContentHash({
15
22
  lintcnDir,
16
23
  tsgolintVersion,
17
24
  }: {
18
25
  lintcnDir: string
19
26
  tsgolintVersion: string
20
- }): Promise<string> {
27
+ }): Promise<{ short: string; full: string }> {
21
28
  const hash = crypto.createHash('sha256')
22
29
 
23
30
  hash.update(`cache-schema:${CACHE_SCHEMA_VERSION}\n`)
24
31
  hash.update(`tsgolint:${tsgolintVersion}\n`)
25
32
  hash.update(`platform:${process.platform}-${process.arch}\n`)
26
33
 
27
- // add Go version
28
- try {
29
- const { stdout } = await execAsync('go', ['version'])
30
- hash.update(`go:${stdout.trim()}\n`)
31
- } catch {
32
- hash.update('go:unknown\n')
34
+ // walk rule subfolders for non-test .go files in sorted order
35
+ const entries = fs.readdirSync(lintcnDir, { withFileTypes: true })
36
+ .filter((e) => { return e.isDirectory() && !e.name.startsWith('.') })
37
+ .sort((a, b) => { return a.name.localeCompare(b.name) })
38
+
39
+ for (const entry of entries) {
40
+ const subDir = path.join(lintcnDir, entry.name)
41
+ const goFiles = fs.readdirSync(subDir)
42
+ .filter((f) => { return f.endsWith('.go') && !f.endsWith('_test.go') })
43
+ .sort()
44
+
45
+ for (const file of goFiles) {
46
+ const content = fs.readFileSync(path.join(subDir, file), 'utf-8')
47
+ hash.update(`file:${entry.name}/${file}\n`)
48
+ hash.update(content)
49
+ }
33
50
  }
34
51
 
35
- // add all rule file contents in sorted order
36
- const files = fs
37
- .readdirSync(lintcnDir)
38
- .filter((f) => {
39
- return f.endsWith('.go')
40
- })
41
- .sort()
42
-
43
- for (const file of files) {
44
- const content = fs.readFileSync(path.join(lintcnDir, file), 'utf-8')
45
- hash.update(`file:${file}\n`)
46
- hash.update(content)
47
- }
48
-
49
- return hash.digest('hex').slice(0, 16)
52
+ const full = hash.digest('hex')
53
+ return { short: full.slice(0, 16), full }
50
54
  }
55
+
56
+