thorns 5.1.9
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/.thornsignore +581 -0
- package/README.md +175 -0
- package/advanced-metrics.js +203 -0
- package/analyzer.js +257 -0
- package/compact-formatter.js +960 -0
- package/dependency-analyzer.js +252 -0
- package/ignore-parser.js +150 -0
- package/index.js +6 -0
- package/lib.js +537 -0
- package/one-liner.sh +24 -0
- package/package.json +71 -0
- package/queries.js +102 -0
- package/run.sh +81 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { relative, dirname, resolve, join, extname } from 'path';
|
|
2
|
+
|
|
3
|
+
export function extractDependencies(tree, sourceCode, filePath, lang) {
|
|
4
|
+
const deps = {
|
|
5
|
+
imports: new Set(),
|
|
6
|
+
exports: new Set(),
|
|
7
|
+
importPaths: new Set(),
|
|
8
|
+
exportedNames: new Set()
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function extractImportPath(node) {
|
|
12
|
+
// Find string literal in import statement
|
|
13
|
+
for (const child of node.children) {
|
|
14
|
+
if (child.type === 'string' || child.type === 'string_literal' ||
|
|
15
|
+
child.type === 'interpreted_string_literal') {
|
|
16
|
+
return child.text.replace(/['"]/g, '');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function extractExportedName(node) {
|
|
23
|
+
for (const child of node.children) {
|
|
24
|
+
if (child.type.includes('identifier') || child.type.includes('name')) {
|
|
25
|
+
return child.text;
|
|
26
|
+
}
|
|
27
|
+
if (child.type.includes('declaration')) {
|
|
28
|
+
return extractExportedName(child);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractCommonJSExports(node) {
|
|
35
|
+
if (node.type === 'assignment_expression' || node.type === 'expression_statement') {
|
|
36
|
+
const text = node.text;
|
|
37
|
+
if (text.startsWith('module.exports') || text.startsWith('exports.')) {
|
|
38
|
+
if (text.includes('{')) {
|
|
39
|
+
const match = text.match(/\{([^}]+)\}/);
|
|
40
|
+
if (match) {
|
|
41
|
+
const names = match[1].split(',').map(n => {
|
|
42
|
+
const parts = n.trim().split(':');
|
|
43
|
+
return parts[0].trim();
|
|
44
|
+
});
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
const match = text.match(/exports\.(\w+)/);
|
|
49
|
+
if (match) return [match[1]];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function traverse(node) {
|
|
57
|
+
const type = node.type;
|
|
58
|
+
|
|
59
|
+
if (type === 'import_statement' || type === 'import_from_statement' ||
|
|
60
|
+
type === 'import_declaration' || type === 'use_declaration') {
|
|
61
|
+
const path = extractImportPath(node);
|
|
62
|
+
if (path) {
|
|
63
|
+
deps.importPaths.add(path);
|
|
64
|
+
deps.imports.add(node.text.slice(0, 80));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (type === 'call_expression') {
|
|
69
|
+
const funcNode = node.children[0];
|
|
70
|
+
if (funcNode && (funcNode.text === 'require' || funcNode.text === 'import')) {
|
|
71
|
+
for (const child of node.children) {
|
|
72
|
+
if (child.type === 'arguments') {
|
|
73
|
+
for (const arg of child.children) {
|
|
74
|
+
if (arg.type === 'string' || arg.type === 'string_fragment' || arg.type === 'template_string') {
|
|
75
|
+
const path = arg.text.replace(/['"]/g, '');
|
|
76
|
+
if (path && !path.includes('${')) {
|
|
77
|
+
deps.importPaths.add(path);
|
|
78
|
+
deps.imports.add(node.text.slice(0, 80));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (type.includes('export')) {
|
|
88
|
+
const name = extractExportedName(node);
|
|
89
|
+
if (name) {
|
|
90
|
+
deps.exportedNames.add(name);
|
|
91
|
+
}
|
|
92
|
+
deps.exports.add(node.text.slice(0, 80));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cjsExports = extractCommonJSExports(node);
|
|
96
|
+
if (cjsExports.length > 0) {
|
|
97
|
+
for (const name of cjsExports) {
|
|
98
|
+
deps.exportedNames.add(name);
|
|
99
|
+
}
|
|
100
|
+
deps.exports.add(node.text.slice(0, 80));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const child of node.children) {
|
|
104
|
+
traverse(child);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
traverse(tree.rootNode);
|
|
109
|
+
return deps;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildDependencyGraph(fileAnalysis) {
|
|
113
|
+
const graph = {
|
|
114
|
+
nodes: new Map(),
|
|
115
|
+
edges: [],
|
|
116
|
+
orphans: new Set(),
|
|
117
|
+
entryPoints: new Set(),
|
|
118
|
+
coupling: new Map()
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Create nodes
|
|
122
|
+
for (const [path, data] of Object.entries(fileAnalysis)) {
|
|
123
|
+
graph.nodes.set(path, {
|
|
124
|
+
imports: data.imports,
|
|
125
|
+
exports: data.exports,
|
|
126
|
+
importPaths: data.importPaths,
|
|
127
|
+
exportedNames: data.exportedNames,
|
|
128
|
+
importedBy: new Set(),
|
|
129
|
+
importsFrom: new Set()
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build edges
|
|
134
|
+
for (const [fromPath, data] of graph.nodes) {
|
|
135
|
+
const fromDir = dirname(fromPath);
|
|
136
|
+
|
|
137
|
+
for (const impPath of data.importPaths) {
|
|
138
|
+
// Try to resolve the import to an actual file
|
|
139
|
+
const resolved = resolveImport(impPath, fromDir, fileAnalysis);
|
|
140
|
+
if (resolved) {
|
|
141
|
+
data.importsFrom.add(resolved);
|
|
142
|
+
const targetNode = graph.nodes.get(resolved);
|
|
143
|
+
if (targetNode) {
|
|
144
|
+
targetNode.importedBy.add(fromPath);
|
|
145
|
+
}
|
|
146
|
+
graph.edges.push({ from: fromPath, to: resolved, type: 'import' });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Identify orphans (files not imported by anyone)
|
|
152
|
+
for (const [path, data] of graph.nodes) {
|
|
153
|
+
if (data.importedBy.size === 0) {
|
|
154
|
+
graph.orphans.add(path);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Identify entry points (files that don't import anything)
|
|
159
|
+
for (const [path, data] of graph.nodes) {
|
|
160
|
+
if (data.importsFrom.size === 0 && data.importedBy.size > 0) {
|
|
161
|
+
graph.entryPoints.add(path);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Calculate coupling (files with most dependencies)
|
|
166
|
+
for (const [path, data] of graph.nodes) {
|
|
167
|
+
const coupling = data.importsFrom.size + data.importedBy.size;
|
|
168
|
+
if (coupling > 0) {
|
|
169
|
+
graph.coupling.set(path, {
|
|
170
|
+
in: data.importedBy.size,
|
|
171
|
+
out: data.importsFrom.size,
|
|
172
|
+
total: coupling
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return graph;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveImport(importPath, fromDir, fileAnalysis) {
|
|
181
|
+
if (importPath.startsWith('.')) {
|
|
182
|
+
const normalized = join(fromDir, importPath).replace(/\\/g, '/');
|
|
183
|
+
const cleanPath = normalized.replace(/^\.\//, '');
|
|
184
|
+
|
|
185
|
+
if (fileAnalysis[cleanPath]) return cleanPath;
|
|
186
|
+
if (fileAnalysis[normalized]) return normalized;
|
|
187
|
+
|
|
188
|
+
const exts = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
|
|
189
|
+
const indexExts = ['/index.js', '/index.ts', '/index.jsx', '/index.tsx'];
|
|
190
|
+
|
|
191
|
+
for (const ext of exts) {
|
|
192
|
+
const withExt = cleanPath.replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, '') + ext;
|
|
193
|
+
if (fileAnalysis[withExt]) return withExt;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const ext of indexExts) {
|
|
197
|
+
const withIdx = cleanPath.replace(/\/$/, '') + ext;
|
|
198
|
+
if (fileAnalysis[withIdx]) return withIdx;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const paths = Object.keys(fileAnalysis);
|
|
203
|
+
const fileName = importPath.split('/').pop();
|
|
204
|
+
const fileNameNoExt = fileName.replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, '');
|
|
205
|
+
|
|
206
|
+
for (const path of paths) {
|
|
207
|
+
const pathParts = path.split('/');
|
|
208
|
+
const pathFileName = pathParts[pathParts.length - 1];
|
|
209
|
+
const pathFileNameNoExt = pathFileName.replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, '');
|
|
210
|
+
|
|
211
|
+
if (pathFileNameNoExt === fileNameNoExt) {
|
|
212
|
+
const importParts = importPath.split('/').filter(p => p && p !== '.');
|
|
213
|
+
let matches = true;
|
|
214
|
+
for (let i = importParts.length - 1, j = pathParts.length - 1; i >= 0 && j >= 0; i--, j--) {
|
|
215
|
+
const importPart = importParts[i].replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, '');
|
|
216
|
+
const pathPart = pathParts[j].replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, '');
|
|
217
|
+
if (importPart !== pathPart) {
|
|
218
|
+
matches = false;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (matches) return path;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function analyzeModules(fileAnalysis, baseDir) {
|
|
230
|
+
const modules = new Map();
|
|
231
|
+
|
|
232
|
+
for (const path of Object.keys(fileAnalysis)) {
|
|
233
|
+
const rel = relative(baseDir, path);
|
|
234
|
+
const parts = rel.split(/[\/\\]/);
|
|
235
|
+
const moduleName = parts[0];
|
|
236
|
+
|
|
237
|
+
if (!modules.has(moduleName)) {
|
|
238
|
+
modules.set(moduleName, {
|
|
239
|
+
files: 0,
|
|
240
|
+
imports: 0,
|
|
241
|
+
exports: 0,
|
|
242
|
+
internalDeps: 0,
|
|
243
|
+
externalDeps: 0
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const mod = modules.get(moduleName);
|
|
248
|
+
mod.files++;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return modules;
|
|
252
|
+
}
|
package/ignore-parser.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join, dirname, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
// Load default ignore patterns from .thornsignore
|
|
8
|
+
function loadDefaultIgnores() {
|
|
9
|
+
const ignorePath = join(__dirname, '.thornsignore');
|
|
10
|
+
if (!existsSync(ignorePath)) {
|
|
11
|
+
return getHardcodedIgnores();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const content = readFileSync(ignorePath, 'utf8');
|
|
16
|
+
return parseIgnoreFile(content);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return getHardcodedIgnores();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fallback hardcoded ignores if file doesn't exist
|
|
23
|
+
function getHardcodedIgnores() {
|
|
24
|
+
return new Set([
|
|
25
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
|
|
26
|
+
'target', 'vendor', '__pycache__', '.pytest_cache', '.mypy_cache',
|
|
27
|
+
'.next', '.nuxt', '.cache', '.parcel-cache', '.vite', '.turbo',
|
|
28
|
+
'coverage', '.nyc_output', '.firebase', '.terraform', '.aws',
|
|
29
|
+
'.azure', '.gcloud', '.vscode', '.idea', '.vs', 'bin', 'obj',
|
|
30
|
+
'.gradle', '.mvn', 'Pods', 'DerivedData', '.bundle',
|
|
31
|
+
// User home hidden folders and caches
|
|
32
|
+
'.config', '.local', '.ssh', '.gnupg', '.kube', '.docker',
|
|
33
|
+
'.npm', '.yarn', '.pnpm', '.bun', '.cargo', '.rustup',
|
|
34
|
+
'.java', '.m2', '.sbt', '.gem', '.rbenv', '.rvm', '.nvm',
|
|
35
|
+
'.python', '.venv_global', '.conda', '.mamba', '.nodenv',
|
|
36
|
+
'.pyenv', '.asdf', '.mise', '.vscode-server', '.cursor',
|
|
37
|
+
'.emacs.d', '.vim', '.neovim', '.nvim', '.zsh', '.bash',
|
|
38
|
+
'.oh-my-zsh', '.oh-my-bash', '.direnv'
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse ignore file format (gitignore-style)
|
|
43
|
+
function parseIgnoreFile(content) {
|
|
44
|
+
const patterns = new Set();
|
|
45
|
+
const lines = content.split('\n');
|
|
46
|
+
|
|
47
|
+
for (let line of lines) {
|
|
48
|
+
line = line.trim();
|
|
49
|
+
|
|
50
|
+
// Skip comments and empty lines
|
|
51
|
+
if (!line || line.startsWith('#')) continue;
|
|
52
|
+
|
|
53
|
+
// Remove trailing slash for directory patterns
|
|
54
|
+
if (line.endsWith('/')) {
|
|
55
|
+
line = line.slice(0, -1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skip negation patterns (!) for now - we're only ignoring
|
|
59
|
+
if (line.startsWith('!')) continue;
|
|
60
|
+
|
|
61
|
+
// Handle wildcards
|
|
62
|
+
if (line.includes('*')) {
|
|
63
|
+
// Convert glob patterns to directory prefixes where possible
|
|
64
|
+
if (line.includes('*.')) {
|
|
65
|
+
// File extension patterns - skip for directory matching
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// For patterns like .cache/, we can still use them
|
|
69
|
+
line = line.replace(/\/\*+$/, ''); // Remove trailing wildcards
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (line) {
|
|
73
|
+
patterns.add(line);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return patterns;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Load project-specific ignore files
|
|
81
|
+
function loadProjectIgnores(rootPath) {
|
|
82
|
+
const patterns = new Set();
|
|
83
|
+
const ignoreFiles = [
|
|
84
|
+
'.gitignore',
|
|
85
|
+
'.dockerignore',
|
|
86
|
+
'.npmignore',
|
|
87
|
+
'.eslintignore',
|
|
88
|
+
'.prettierignore',
|
|
89
|
+
'.thornsignore'
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
for (const file of ignoreFiles) {
|
|
93
|
+
const path = join(rootPath, file);
|
|
94
|
+
if (existsSync(path)) {
|
|
95
|
+
try {
|
|
96
|
+
const content = readFileSync(path, 'utf8');
|
|
97
|
+
const filePatterns = parseIgnoreFile(content);
|
|
98
|
+
for (const pattern of filePatterns) {
|
|
99
|
+
patterns.add(pattern);
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Ignore read errors
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return patterns;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if a path should be ignored
|
|
111
|
+
export function shouldIgnore(path, ignorePatterns) {
|
|
112
|
+
const parts = path.split(/[\/\\]/);
|
|
113
|
+
|
|
114
|
+
// Check each part of the path
|
|
115
|
+
for (const part of parts) {
|
|
116
|
+
if (ignorePatterns.has(part)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for patterns with wildcards
|
|
121
|
+
for (const pattern of ignorePatterns) {
|
|
122
|
+
if (pattern.includes('*')) {
|
|
123
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
124
|
+
if (regex.test(part)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check for prefix matches (e.g., ".cache" matches ".cache-loader")
|
|
130
|
+
if (part.startsWith(pattern) && pattern.startsWith('.')) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build combined ignore set
|
|
140
|
+
export function buildIgnoreSet(rootPath) {
|
|
141
|
+
const defaultIgnores = loadDefaultIgnores();
|
|
142
|
+
const projectIgnores = loadProjectIgnores(rootPath);
|
|
143
|
+
|
|
144
|
+
// Merge all patterns
|
|
145
|
+
const combined = new Set([...defaultIgnores, ...projectIgnores]);
|
|
146
|
+
|
|
147
|
+
return combined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { loadDefaultIgnores, loadProjectIgnores };
|