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,101 @@
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
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { getLintcnDir } from "../paths.js";
7
+ import { generateEditorGoFiles } from "../codegen.js";
8
+ import { ensureTsgolintSource, DEFAULT_TSGOLINT_VERSION } from "../cache.js";
9
+ 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}`;
15
+ }
16
+ return url;
17
+ }
18
+ function deriveTestUrl(rawUrl) {
19
+ return rawUrl.replace(/\.go$/, '_test.go');
20
+ }
21
+ async function fetchFile(url) {
22
+ try {
23
+ const response = await fetch(url);
24
+ if (!response.ok) {
25
+ return null;
26
+ }
27
+ return await response.text();
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function rewritePackageName(content) {
34
+ // Rewrite first package declaration to package lintcn
35
+ return content.replace(/^package\s+\w+/m, 'package lintcn');
36
+ }
37
+ function ensureSourceComment(content, sourceUrl) {
38
+ if (content.includes('// lintcn:source')) {
39
+ return content;
40
+ }
41
+ // Insert source comment after the first lintcn: comment block, or at the very top
42
+ const lines = content.split('\n');
43
+ let insertIndex = 0;
44
+ for (let i = 0; i < lines.length; i++) {
45
+ if (lines[i].startsWith('// lintcn:')) {
46
+ insertIndex = i + 1;
47
+ }
48
+ else if (insertIndex > 0) {
49
+ break;
50
+ }
51
+ }
52
+ lines.splice(insertIndex, 0, `// lintcn:source ${sourceUrl}`);
53
+ return lines.join('\n');
54
+ }
55
+ export async function addRule(url) {
56
+ const rawUrl = normalizeGithubUrl(url);
57
+ console.log(`Fetching ${rawUrl}...`);
58
+ const content = await fetchFile(rawUrl);
59
+ if (!content) {
60
+ throw new Error(`Could not fetch rule from ${rawUrl}`);
61
+ }
62
+ // validate it looks like a Go file with a rule
63
+ if (!content.includes('rule.Rule')) {
64
+ console.warn('Warning: no rule.Rule reference found in this file. Are you sure this is a tsgolint rule?');
65
+ }
66
+ // derive filename from URL
67
+ const urlPath = new URL(rawUrl).pathname;
68
+ const fileName = path.basename(urlPath);
69
+ if (!fileName.endsWith('.go')) {
70
+ throw new Error(`URL must point to a .go file, got: ${fileName}`);
71
+ }
72
+ const lintcnDir = getLintcnDir();
73
+ fs.mkdirSync(lintcnDir, { recursive: true });
74
+ // write the rule file
75
+ const filePath = path.join(lintcnDir, fileName);
76
+ if (fs.existsSync(filePath)) {
77
+ console.log(`Overwriting existing ${fileName}`);
78
+ }
79
+ let processed = rewritePackageName(content);
80
+ processed = ensureSourceComment(processed, url);
81
+ fs.writeFileSync(filePath, processed);
82
+ console.log(`Added ${fileName}`);
83
+ // try to fetch matching test file
84
+ const testUrl = deriveTestUrl(rawUrl);
85
+ const testContent = await fetchFile(testUrl);
86
+ if (testContent) {
87
+ const testFileName = fileName.replace(/\.go$/, '_test.go');
88
+ const testProcessed = rewritePackageName(testContent);
89
+ fs.writeFileSync(path.join(lintcnDir, testFileName), testProcessed);
90
+ console.log(`Added ${testFileName}`);
91
+ }
92
+ // ensure .tsgolint source is available and generate editor support files
93
+ const tsgolintDir = await ensureTsgolintSource(DEFAULT_TSGOLINT_VERSION);
94
+ // create .tsgolint symlink inside .lintcn for gopls
95
+ const tsgolintLink = path.join(lintcnDir, '.tsgolint');
96
+ if (!fs.existsSync(tsgolintLink)) {
97
+ fs.symlinkSync(tsgolintDir, tsgolintLink);
98
+ }
99
+ generateEditorGoFiles(lintcnDir);
100
+ console.log('Editor support files generated (go.work, go.mod)');
101
+ }
@@ -0,0 +1,10 @@
1
+ export declare function buildBinary({ rebuild, tsgolintVersion, }: {
2
+ rebuild: boolean;
3
+ tsgolintVersion: string;
4
+ }): Promise<string>;
5
+ export declare function lint({ rebuild, tsgolintVersion, passthroughArgs, }: {
6
+ rebuild: boolean;
7
+ tsgolintVersion: string;
8
+ passthroughArgs: string[];
9
+ }): Promise<number>;
10
+ //# sourceMappingURL=lint.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,78 @@
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
+ import fs from 'node:fs';
4
+ import { spawn } from 'node:child_process';
5
+ import { getLintcnDir } from "../paths.js";
6
+ import { discoverRules } from "../discover.js";
7
+ import { generateBuildWorkspace } from "../codegen.js";
8
+ import { ensureTsgolintSource, cachedBinaryExists, getBinaryPath, getBuildDir, getBinDir } from "../cache.js";
9
+ import { computeContentHash } from "../hash.js";
10
+ import { execAsync } from "../exec.js";
11
+ async function checkGoInstalled() {
12
+ try {
13
+ await execAsync('go', ['version']);
14
+ }
15
+ catch {
16
+ throw new Error('Go 1.26+ is required to build rules.\n' +
17
+ 'Install from https://go.dev/dl/');
18
+ }
19
+ }
20
+ export async function buildBinary({ rebuild, tsgolintVersion, }) {
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
+ }
26
+ const rules = discoverRules(lintcnDir);
27
+ if (rules.length === 0) {
28
+ throw new Error('No rules found in .lintcn/. Run `lintcn add <url>` to add rules.');
29
+ }
30
+ console.log(`Found ${rules.length} custom rule${rules.length === 1 ? '' : 's'} (tsgolint ${tsgolintVersion})`);
31
+ // ensure tsgolint source
32
+ const tsgolintDir = await ensureTsgolintSource(tsgolintVersion);
33
+ // compute content hash
34
+ const contentHash = await computeContentHash({
35
+ lintcnDir,
36
+ tsgolintVersion,
37
+ });
38
+ // check cache
39
+ if (!rebuild && cachedBinaryExists(contentHash)) {
40
+ console.log('Using cached binary');
41
+ return getBinaryPath(contentHash);
42
+ }
43
+ // generate build workspace
44
+ const buildDir = getBuildDir();
45
+ console.log('Generating build workspace...');
46
+ generateBuildWorkspace({
47
+ buildDir,
48
+ tsgolintDir,
49
+ lintcnDir,
50
+ rules,
51
+ });
52
+ // compile
53
+ const binDir = getBinDir();
54
+ fs.mkdirSync(binDir, { recursive: true });
55
+ const binaryPath = getBinaryPath(contentHash);
56
+ console.log('Compiling custom tsgolint binary...');
57
+ await execAsync('go', ['build', '-o', binaryPath, './wrapper'], {
58
+ cwd: buildDir,
59
+ });
60
+ console.log('Build complete');
61
+ return binaryPath;
62
+ }
63
+ export async function lint({ rebuild, tsgolintVersion, passthroughArgs, }) {
64
+ const binaryPath = await buildBinary({ rebuild, tsgolintVersion });
65
+ // run the binary with passthrough args, inheriting stdio
66
+ return new Promise((resolve) => {
67
+ const proc = spawn(binaryPath, passthroughArgs, {
68
+ stdio: 'inherit',
69
+ });
70
+ proc.on('error', (err) => {
71
+ console.error(`Failed to run binary: ${err.message}`);
72
+ resolve(1);
73
+ });
74
+ proc.on('close', (code) => {
75
+ resolve(code ?? 1);
76
+ });
77
+ });
78
+ }
@@ -0,0 +1,2 @@
1
+ export declare function listRules(): void;
2
+ //# sourceMappingURL=list.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../src/commands/list.ts"],"names":[],"mappings":"AAMA,wBAAgB,SAAS,IAAI,IAAI,CA0BhC"}
@@ -0,0 +1,24 @@
1
+ // lintcn list — list installed rules with metadata from .lintcn/
2
+ import fs from 'node:fs';
3
+ import { getLintcnDir } from "../paths.js";
4
+ import { discoverRules } from "../discover.js";
5
+ export function listRules() {
6
+ const lintcnDir = getLintcnDir();
7
+ if (!fs.existsSync(lintcnDir)) {
8
+ console.log('No .lintcn/ directory found. Run `lintcn add <url>` to add rules.');
9
+ return;
10
+ }
11
+ const rules = discoverRules(lintcnDir);
12
+ if (rules.length === 0) {
13
+ console.log('No rules installed. Run `lintcn add <url>` to add rules.');
14
+ return;
15
+ }
16
+ console.log('Installed rules:\n');
17
+ const maxNameLen = Math.max(...rules.map((r) => { return r.name.length; }));
18
+ for (const rule of rules) {
19
+ const name = rule.name.padEnd(maxNameLen + 2);
20
+ const desc = rule.description || '(no description)';
21
+ console.log(` ${name}${desc}`);
22
+ }
23
+ console.log(`\n${rules.length} rule${rules.length === 1 ? '' : 's'} installed`);
24
+ }
@@ -0,0 +1,2 @@
1
+ export declare function removeRule(name: string): void;
2
+ //# sourceMappingURL=remove.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,31 @@
1
+ // lintcn remove <name> — delete a rule and its test file from .lintcn/
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getLintcnDir } from "../paths.js";
5
+ import { discoverRules } from "../discover.js";
6
+ export function removeRule(name) {
7
+ const lintcnDir = getLintcnDir();
8
+ if (!fs.existsSync(lintcnDir)) {
9
+ throw new Error('No .lintcn/ directory found.');
10
+ }
11
+ // match by lintcn:name metadata or by filename
12
+ const rules = discoverRules(lintcnDir);
13
+ const normalizedName = name.replace(/-/g, '_');
14
+ const match = rules.find((r) => {
15
+ return r.name === name || r.fileName.replace(/\.go$/, '') === normalizedName;
16
+ });
17
+ if (!match) {
18
+ throw new Error(`Rule "${name}" not found. Run \`lintcn list\` to see installed rules.`);
19
+ }
20
+ // delete rule file
21
+ const rulePath = path.join(lintcnDir, match.fileName);
22
+ fs.rmSync(rulePath);
23
+ console.log(`Removed ${match.fileName}`);
24
+ // delete test file if exists
25
+ const testFileName = match.fileName.replace(/\.go$/, '_test.go');
26
+ const testPath = path.join(lintcnDir, testFileName);
27
+ if (fs.existsSync(testPath)) {
28
+ fs.rmSync(testPath);
29
+ console.log(`Removed ${testFileName}`);
30
+ }
31
+ }
@@ -0,0 +1,16 @@
1
+ export interface RuleMetadata {
2
+ /** kebab-case rule name from // lintcn:name or derived from filename */
3
+ name: string;
4
+ /** one-line description from // lintcn:description */
5
+ description: string;
6
+ /** original source URL from // lintcn:source */
7
+ source: string;
8
+ /** exported Go variable name like NoFloatingPromisesRule */
9
+ varName: string;
10
+ /** filename relative to .lintcn/ */
11
+ fileName: string;
12
+ }
13
+ export declare function parseMetadata(content: string): Record<string, string>;
14
+ export declare function parseRuleVar(content: string): string | undefined;
15
+ export declare function discoverRules(lintcnDir: string): RuleMetadata[];
16
+ //# sourceMappingURL=discover.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,44 @@
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
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ const RULE_VAR_RE = /^var\s+(\w+)\s*=\s*rule\.Rule\s*\{/m;
6
+ const METADATA_RE = /^\/\/\s*lintcn:(\w+)\s+(.+)$/gm;
7
+ export function parseMetadata(content) {
8
+ const meta = {};
9
+ for (const match of content.matchAll(METADATA_RE)) {
10
+ meta[match[1]] = match[2].trim();
11
+ }
12
+ return meta;
13
+ }
14
+ export function parseRuleVar(content) {
15
+ const match = content.match(RULE_VAR_RE);
16
+ return match?.[1];
17
+ }
18
+ export function discoverRules(lintcnDir) {
19
+ if (!fs.existsSync(lintcnDir)) {
20
+ return [];
21
+ }
22
+ const files = fs.readdirSync(lintcnDir).filter((f) => {
23
+ return f.endsWith('.go') && !f.endsWith('_test.go');
24
+ });
25
+ const rules = [];
26
+ for (const fileName of files) {
27
+ const filePath = path.join(lintcnDir, fileName);
28
+ const content = fs.readFileSync(filePath, 'utf-8');
29
+ const varName = parseRuleVar(content);
30
+ if (!varName) {
31
+ continue;
32
+ }
33
+ const meta = parseMetadata(content);
34
+ const baseName = fileName.replace(/\.go$/, '');
35
+ rules.push({
36
+ name: meta.name || baseName.replace(/_/g, '-'),
37
+ description: meta.description || '',
38
+ source: meta.source || '',
39
+ varName,
40
+ fileName,
41
+ });
42
+ }
43
+ return rules;
44
+ }
package/dist/exec.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface ExecResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ exitCode: number;
5
+ }
6
+ export declare function execAsync(command: string, args: string[], options?: {
7
+ cwd?: string;
8
+ stdio?: 'pipe' | 'inherit';
9
+ }): Promise<ExecResult>;
10
+ //# sourceMappingURL=exec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,wBAAgB,SAAS,CACvB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GACrD,OAAO,CAAC,UAAU,CAAC,CAkCrB"}
package/dist/exec.js ADDED
@@ -0,0 +1,34 @@
1
+ // Async process execution utility using spawn.
2
+ // Returns stdout/stderr as strings, rejects on non-zero exit code.
3
+ import { spawn } from 'node:child_process';
4
+ export function execAsync(command, args, options) {
5
+ return new Promise((resolve, reject) => {
6
+ const proc = spawn(command, args, {
7
+ cwd: options?.cwd,
8
+ stdio: options?.stdio === 'inherit' ? 'inherit' : 'pipe',
9
+ });
10
+ let stdout = '';
11
+ let stderr = '';
12
+ if (proc.stdout) {
13
+ proc.stdout.on('data', (data) => {
14
+ stdout += data.toString();
15
+ });
16
+ }
17
+ if (proc.stderr) {
18
+ proc.stderr.on('data', (data) => {
19
+ stderr += data.toString();
20
+ });
21
+ }
22
+ proc.on('error', (err) => {
23
+ reject(new Error(`Failed to execute ${command}: ${err.message}`, { cause: err }));
24
+ });
25
+ proc.on('close', (code) => {
26
+ const exitCode = code ?? 1;
27
+ if (exitCode !== 0 && options?.stdio !== 'inherit') {
28
+ reject(new Error(`${command} exited with code ${exitCode}\n${stderr}`));
29
+ return;
30
+ }
31
+ resolve({ stdout, stderr, exitCode });
32
+ });
33
+ });
34
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function computeContentHash({ lintcnDir, tsgolintVersion, }: {
2
+ lintcnDir: string;
3
+ tsgolintVersion: string;
4
+ }): Promise<string>;
5
+ //# sourceMappingURL=hash.d.ts.map
@@ -0,0 +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"}
package/dist/hash.js ADDED
@@ -0,0 +1,33 @@
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
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { execAsync } from "./exec.js";
8
+ export async function computeContentHash({ lintcnDir, tsgolintVersion, }) {
9
+ const hash = crypto.createHash('sha256');
10
+ hash.update(`tsgolint:${tsgolintVersion}\n`);
11
+ hash.update(`platform:${process.platform}-${process.arch}\n`);
12
+ // add Go version
13
+ try {
14
+ const { stdout } = await execAsync('go', ['version']);
15
+ hash.update(`go:${stdout.trim()}\n`);
16
+ }
17
+ catch {
18
+ hash.update('go:unknown\n');
19
+ }
20
+ // add all rule file contents in sorted order
21
+ const files = fs
22
+ .readdirSync(lintcnDir)
23
+ .filter((f) => {
24
+ return f.endsWith('.go');
25
+ })
26
+ .sort();
27
+ for (const file of files) {
28
+ const content = fs.readFileSync(path.join(lintcnDir, file), 'utf-8');
29
+ hash.update(`file:${file}\n`);
30
+ hash.update(content);
31
+ }
32
+ return hash.digest('hex').slice(0, 16);
33
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,8 @@
1
- export declare 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';
2
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,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"}
package/dist/index.js CHANGED
@@ -1 +1,6 @@
1
- export const version = '0.0.1';
1
+ export { discoverRules, parseMetadata, parseRuleVar } from "./discover.js";
2
+ export { addRule } from "./commands/add.js";
3
+ export { lint, buildBinary } from "./commands/lint.js";
4
+ export { listRules } from "./commands/list.js";
5
+ export { removeRule } from "./commands/remove.js";
6
+ export { DEFAULT_TSGOLINT_VERSION } from "./cache.js";
@@ -0,0 +1,2 @@
1
+ export declare function getLintcnDir(): string;
2
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAIA,wBAAgB,YAAY,IAAI,MAAM,CAErC"}
package/dist/paths.js ADDED
@@ -0,0 +1,5 @@
1
+ // Resolve the .lintcn/ directory path relative to cwd.
2
+ import path from 'node:path';
3
+ export function getLintcnDir() {
4
+ return path.resolve(process.cwd(), '.lintcn');
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lintcn",
3
- "version": "0.0.1",
3
+ "version": "0.2.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",
@@ -24,12 +24,9 @@
24
24
  "files": [
25
25
  "src",
26
26
  "dist",
27
- "README.md"
27
+ "README.md",
28
+ "CHANGELOG.md"
28
29
  ],
29
- "scripts": {
30
- "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
31
- "prepublishOnly": "pnpm build"
32
- },
33
30
  "keywords": [
34
31
  "lint",
35
32
  "linter",
@@ -53,7 +50,13 @@
53
50
  },
54
51
  "license": "MIT",
55
52
  "devDependencies": {
56
- "typescript": "5.8.2",
57
- "@types/node": "^22.0.0"
53
+ "@types/node": "^22.0.0",
54
+ "typescript": "5.8.2"
55
+ },
56
+ "dependencies": {
57
+ "goke": "^6.3.0"
58
+ },
59
+ "scripts": {
60
+ "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js"
58
61
  }
59
- }
62
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,106 @@
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
5
+
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { execAsync } from './exec.ts'
10
+
11
+ // Pinned tsgolint version — updated with each lintcn release.
12
+ // This ensures reproducible builds: every user on the same lintcn version
13
+ // compiles rules against the same tsgolint API. Changing this is a conscious
14
+ // decision — tsgolint API changes can break user rules.
15
+ export const DEFAULT_TSGOLINT_VERSION = 'v0.9.2'
16
+
17
+ export function getCacheDir(): string {
18
+ return path.join(os.homedir(), '.cache', 'lintcn')
19
+ }
20
+
21
+ export function getTsgolintSourceDir(version: string): string {
22
+ return path.join(getCacheDir(), 'tsgolint', version)
23
+ }
24
+
25
+ export function getBinDir(): string {
26
+ return path.join(getCacheDir(), 'bin')
27
+ }
28
+
29
+ export function getBinaryPath(contentHash: string): string {
30
+ return path.join(getBinDir(), contentHash)
31
+ }
32
+
33
+ export function getBuildDir(): string {
34
+ return path.join(getCacheDir(), 'build')
35
+ }
36
+
37
+ export async function ensureTsgolintSource(version: string): Promise<string> {
38
+ const sourceDir = getTsgolintSourceDir(version)
39
+ const readyMarker = path.join(sourceDir, '.lintcn-ready')
40
+
41
+ if (fs.existsSync(readyMarker)) {
42
+ return sourceDir
43
+ }
44
+
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)
58
+
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()
65
+
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'),
73
+ })
74
+ }
75
+ }
76
+
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))
87
+ }
88
+ console.log(`Copied ${files.length} collection files`)
89
+ }
90
+
91
+ // write ready marker
92
+ fs.writeFileSync(readyMarker, new Date().toISOString())
93
+ console.log('tsgolint source ready')
94
+
95
+ return sourceDir
96
+ }
97
+
98
+ export function cachedBinaryExists(contentHash: string): boolean {
99
+ const binPath = getBinaryPath(contentHash)
100
+ try {
101
+ fs.accessSync(binPath, fs.constants.X_OK)
102
+ return true
103
+ } catch {
104
+ return false
105
+ }
106
+ }