codebase-onboarder 0.4.1 → 0.5.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/README.md +53 -2
- package/cli/explorer/app.js +295 -0
- package/cli/explorer/commands.js +197 -0
- package/cli/explorer/session.js +161 -0
- package/cli/explorer/views.js +549 -0
- package/cli/main.js +36 -8
- package/package.json +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// One repository, loaded into memory, the way the website loads it.
|
|
2
|
+
//
|
|
3
|
+
// This is the whole reason the terminal app can stand in for the site: it runs
|
|
4
|
+
// the *same* modules in the *same* order as `server/apiScan.js` — FileSource →
|
|
5
|
+
// scanRepo → detectManifest → computeFacts → buildSearchIndex — and then the
|
|
6
|
+
// same projections the browser's views are projections of (health, layers,
|
|
7
|
+
// patterns, security, stack, tour). Nothing here re-implements analysis, so the
|
|
8
|
+
// two surfaces cannot drift into telling different stories about one repo.
|
|
9
|
+
//
|
|
10
|
+
// Everything below the loader is pure data plus a few lookups. The loader is
|
|
11
|
+
// the only async part, and it is the only part that touches the filesystem.
|
|
12
|
+
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { nodeFileSource } from '../../server/fileSourceNode.js';
|
|
17
|
+
import { buildSearchIndex } from '../../server/searchIndex.js';
|
|
18
|
+
import { searchDocuments } from '../../server/apiSearch.js';
|
|
19
|
+
import { expandHome, resolveInside } from '../../server/paths.js';
|
|
20
|
+
import { scanRepo } from '../../shared/analyzer/scan.js';
|
|
21
|
+
import { detectManifest } from '../../shared/analyzer/services.js';
|
|
22
|
+
import { computeFacts, roleOf } from '../../shared/analyzer/graph.js';
|
|
23
|
+
import { analyzeHealth } from '../../shared/analyzer/health.js';
|
|
24
|
+
import { computeLayers, detectPatterns } from '../../shared/analyzer/patterns.js';
|
|
25
|
+
import { summarizeSecurity } from '../../shared/analyzer/security.js';
|
|
26
|
+
import { analyzeStack } from '../../shared/analyzer/stack.js';
|
|
27
|
+
import { buildTourStops } from '../../shared/analyzer/tour.js';
|
|
28
|
+
import { explainFile, explainFolder, explainOverview } from '../../shared/analyzer/explainLocal.js';
|
|
29
|
+
import { languageLabel } from '../../shared/analyzer/languages/index.js';
|
|
30
|
+
|
|
31
|
+
// Scan a directory and return the loaded repo, or throw an Error whose message
|
|
32
|
+
// is safe to print straight to the user. `target` may be `~`, a relative path,
|
|
33
|
+
// or anything `expandHome` understands; it defaults to the current directory.
|
|
34
|
+
export async function openRepo(target = '.', { onProgress } = {}) {
|
|
35
|
+
const root = expandHome(String(target || '.'));
|
|
36
|
+
const stat = await fs.stat(root).catch(() => null);
|
|
37
|
+
if (!stat) throw new Error('No such folder: ' + root);
|
|
38
|
+
if (!stat.isDirectory()) throw new Error('Not a folder: ' + root);
|
|
39
|
+
|
|
40
|
+
const source = nodeFileSource(root);
|
|
41
|
+
const scan = await scanRepo(source, { onProgress });
|
|
42
|
+
const manifest = await detectManifest(source);
|
|
43
|
+
const facts = computeFacts(scan, manifest);
|
|
44
|
+
const searchIndex = await buildSearchIndex(source, scan.files.map((f) => f.path));
|
|
45
|
+
|
|
46
|
+
// The layers projection is an input to patterns, not a parallel view of the
|
|
47
|
+
// same thing — `detectPatterns` reads the layer depths to place a file in the
|
|
48
|
+
// architecture. Compute once, share.
|
|
49
|
+
const layers = computeLayers(scan, facts);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
root,
|
|
53
|
+
name: scan.name || path.basename(root),
|
|
54
|
+
scan,
|
|
55
|
+
facts,
|
|
56
|
+
manifest,
|
|
57
|
+
searchIndex,
|
|
58
|
+
layers,
|
|
59
|
+
health: analyzeHealth(scan, facts),
|
|
60
|
+
patterns: detectPatterns(scan, facts, manifest, layers),
|
|
61
|
+
security: summarizeSecurity(scan),
|
|
62
|
+
stack: analyzeStack(manifest, scan.stats.languages || {}),
|
|
63
|
+
tour: buildTourStops(scan, facts),
|
|
64
|
+
languages: languageRows(scan),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// LOC per language, biggest first, with the display label the site uses — so
|
|
69
|
+
// `JavaScript` here and `JavaScript` there are the same string.
|
|
70
|
+
function languageRows(scan) {
|
|
71
|
+
return Object.entries(scan.stats.languages || {})
|
|
72
|
+
.map(([id, loc]) => ({ id, loc, label: languageLabel(id) }))
|
|
73
|
+
.sort((a, b) => b.loc - a.loc || a.label.localeCompare(b.label));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The one place a typed path becomes a real file. People type `logger.js`, not
|
|
77
|
+
// `server/logger.js`, and a tool that says "no such file" for a file that is
|
|
78
|
+
// right there is worse than no tool. So: exact, then folder, then suffix, then
|
|
79
|
+
// basename, then a substring. Ambiguity is reported with the candidates rather
|
|
80
|
+
// than guessed at — silently picking the wrong `index.js` is the failure mode
|
|
81
|
+
// that makes people stop trusting a map.
|
|
82
|
+
export function resolveTarget(repo, typed) {
|
|
83
|
+
const raw = String(typed || '').trim().replace(/^\.\//, '');
|
|
84
|
+
if (!raw) return { error: 'Name a file or folder.' };
|
|
85
|
+
|
|
86
|
+
const files = repo.scan.files;
|
|
87
|
+
if (!repo.byPath) repo.byPath = new Map(files.map((f) => [f.path, f]));
|
|
88
|
+
if (repo.byPath.has(raw)) return { file: repo.byPath.get(raw) };
|
|
89
|
+
|
|
90
|
+
const folder = folderFor(repo, raw);
|
|
91
|
+
if (folder) return { folder };
|
|
92
|
+
|
|
93
|
+
const lower = raw.toLowerCase();
|
|
94
|
+
const suffix = files.filter((f) => f.path.endsWith('/' + raw));
|
|
95
|
+
if (suffix.length === 1) return { file: suffix[0] };
|
|
96
|
+
if (suffix.length > 1) return { error: 'Which one?', candidates: suffix.map((f) => f.path) };
|
|
97
|
+
|
|
98
|
+
const base = files.filter((f) => f.name.toLowerCase() === lower);
|
|
99
|
+
if (base.length === 1) return { file: base[0] };
|
|
100
|
+
if (base.length > 1) return { error: 'Which one?', candidates: base.map((f) => f.path) };
|
|
101
|
+
|
|
102
|
+
const loose = files.filter((f) => f.path.toLowerCase().includes(lower));
|
|
103
|
+
if (loose.length) {
|
|
104
|
+
loose.sort((a, b) => a.path.length - b.path.length);
|
|
105
|
+
return { error: 'No exact match. Did you mean:', candidates: loose.slice(0, 8).map((f) => f.path) };
|
|
106
|
+
}
|
|
107
|
+
return { error: 'Nothing in this repo matches "' + raw + '".' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function folderFor(repo, typed) {
|
|
111
|
+
const clean = typed.replace(/\/+$/, '');
|
|
112
|
+
if (repo.scan.folders.some((d) => d.path === clean)) {
|
|
113
|
+
return repo.scan.folders.find((d) => d.path === clean);
|
|
114
|
+
}
|
|
115
|
+
const matches = repo.scan.folders.filter((d) => d.path.endsWith('/' + clean) || d.name === clean);
|
|
116
|
+
return matches.length === 1 ? matches[0] : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Read a file through the same containment check the HTTP route uses. A tool
|
|
120
|
+
// whose job is showing you one repository has no business reading any path on
|
|
121
|
+
// the machine, and going through `resolveInside` means there is no second,
|
|
122
|
+
// looser door to get there.
|
|
123
|
+
export async function readRepoFile(repo, repoPath) {
|
|
124
|
+
const abs = resolveInside(repo.root, repoPath);
|
|
125
|
+
if (!abs) throw new Error('That path is outside the repository.');
|
|
126
|
+
return fs.readFile(abs, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The website's search, verbatim: same TF-IDF ranking, same query language
|
|
130
|
+
// (`ext:`, `path:`, `-exclude`, `"phrases"`, `/regex/`), same result shape. A
|
|
131
|
+
// query that works in one surface works in the other, because it is the same
|
|
132
|
+
// function.
|
|
133
|
+
export function searchRepo(repo, query, { limit = 12 } = {}) {
|
|
134
|
+
return searchDocuments(repo.searchIndex, query, { limit });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function explainRepoFile(repo, file) {
|
|
138
|
+
return explainFile(file.path, file, repo.facts);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function explainRepoFolder(repo, folder) {
|
|
142
|
+
return explainFolder(folder.path, repo.scan, repo.facts);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function explainRepoOverview(repo) {
|
|
146
|
+
return explainOverview(repo.scan, repo.facts, repo.manifest);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Short facts about a file, in the order `docs.js` states them, so the terminal
|
|
150
|
+
// and the generated docs never describe the same file differently.
|
|
151
|
+
export function fileFacts(repo, file) {
|
|
152
|
+
return {
|
|
153
|
+
role: roleOf(file.path, repo.facts),
|
|
154
|
+
loc: file.loc || 0,
|
|
155
|
+
complexity: file.complexity || 0,
|
|
156
|
+
fanIn: repo.facts.fanIn[file.path] || 0,
|
|
157
|
+
fanOut: repo.facts.fanOut[file.path] || 0,
|
|
158
|
+
inCycle: (repo.facts.inCycle || []).includes(file.path),
|
|
159
|
+
findings: (file.findings || []).length,
|
|
160
|
+
};
|
|
161
|
+
}
|