codebase-onboarder 0.4.0 → 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.
@@ -1,15 +1,33 @@
1
1
  // Java analyzer
2
+ //
3
+ // Java is package-rooted: a source file's path *is* its package spelled with
4
+ // slashes, under a source root (`src/main/java` in Maven, `src/main/java` in
5
+ // Gradle, or a plain directory in a one-off project). That single fact is what
6
+ // turns an import into a file, and it is why Java can have a real import graph
7
+ // at all — the C# analyzer sitting next to this one cannot, because a C# file
8
+ // carries no path↔namespace correspondence to invert.
2
9
  import { blankComments, lineCounter, uniqueBy } from '../util.js';
3
10
 
4
11
  export const extensions = ['.java'];
5
12
 
13
+ const PACKAGE_RE = /^[ \t]*package\s+([a-zA-Z0-9_.]+)\s*;/m;
14
+
6
15
  export function analyze(source, path) {
7
16
  const clean = blankComments(source);
8
17
  const lineAt = lineCounter(clean);
9
18
 
19
+ // The file's own package, captured first because every import in the file is
20
+ // resolved relative to it: `resolveImport` has to know where the source root
21
+ // ends and the package begins, and this declaration is the only place that is
22
+ // written down. A file with no `package` is in the default package, which is a
23
+ // real (if unfashionable) case rather than an error.
24
+ const packageName = (clean.match(PACKAGE_RE) || [])[1] || '';
25
+
10
26
  const imports = [];
11
27
  for (const m of clean.matchAll(/\bimport\s+(static\s+)?([a-zA-Z0-9_.]+)/g)) {
12
- imports.push({ spec: m[2], static: !!m[1], line: lineAt(m.index) });
28
+ // `packageName` rides along on the import so the resolver can do its job
29
+ // from the import object alone — which is the fifth argument scan.js passes.
30
+ imports.push({ spec: m[2], static: !!m[1], packageName, line: lineAt(m.index) });
13
31
  }
14
32
 
15
33
  const classes = [];
@@ -26,14 +44,71 @@ export function analyze(source, path) {
26
44
  const hasMain = /public\s+static\s+void\s+main\s*\(/.test(clean);
27
45
 
28
46
  return {
29
- imports,
47
+ imports: uniqueBy(imports, (i) => i.spec + (i.static ? ' static' : '')),
30
48
  exports: classes.map(c => ({ name: c.name, kind: 'class' })),
31
49
  functions: [],
32
50
  classes,
33
- hasMain
51
+ hasMain,
52
+ packageName,
34
53
  };
35
54
  }
36
55
 
37
- export function resolveImport(spec, fromPath, has, context = {}) {
56
+ // The source root: the part of this file's path that sits *above* its package.
57
+ // `src/main/java/com/example/app/AppController.java` with package
58
+ // `com.example.app` gives `src/main/java/` — and that prefix is exactly what
59
+ // turns `com.example.model.User` into a path worth looking for.
60
+ //
61
+ // Returns '' when the file declares no package: with nothing to strip there is
62
+ // no way to know where the root is, and guessing would mean inventing files.
63
+ export function sourceRootFor(fromPath, packageName) {
64
+ if (!packageName) return null;
65
+ const suffix = packageName.replace(/\./g, '/');
66
+ const normalized = String(fromPath).replace(/\\/g, '/');
67
+ // Two shapes, and both are real. With a source root the package segment is
68
+ // preceded by a directory (`src/main/java/com/acme/…`); in a project with no
69
+ // root at all the path *begins* with the package (`com/acme/…`) and the root
70
+ // is the empty string. That is not the same as "no root" — it is the repo top,
71
+ // which is a perfectly good answer.
72
+ const nested = normalized.lastIndexOf('/' + suffix + '/');
73
+ if (nested >= 0) return normalized.slice(0, nested + 1);
74
+ if (normalized === suffix || normalized.startsWith(suffix + '/')) return '';
75
+ // The package is not in the path at all (a generated source dir, a file moved
76
+ // out of its package). No root can be inferred, and inventing one would put a
77
+ // node in the graph that does not exist.
78
+ return null;
79
+ }
80
+
81
+ // Candidate file paths for an import, longest spec first.
82
+ //
83
+ // The longest is the obvious one (`model.User` → `…/model/User.java`). The
84
+ // shorter ones exist because Java lets you import a *nested* type
85
+ // (`com.example.Outer.Inner` lives in `Outer.java`) and a *static member*
86
+ // (`com.example.Constants.MAX` lives in `Constants.java`). Rather than
87
+ // special-case the syntax, walk back a segment at a time and take the first
88
+ // that exists. The static flag only decides where the walk starts, so the
89
+ // common case does not pay for the rare one.
90
+ function candidatesFor(spec, root, { static: isStatic = false } = {}) {
91
+ const parts = spec.split('.').filter(Boolean);
92
+ const out = [];
93
+ const start = isStatic && parts.length > 1 ? 1 : 0;
94
+ for (let end = parts.length; end > start; end -= 1) {
95
+ out.push(root + parts.slice(0, end).join('/') + '.java');
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export function resolveImport(spec, fromPath, has, context = {}, meta = {}) {
101
+ const root = sourceRootFor(fromPath, meta.packageName);
102
+ // `null` means "no root could be inferred". `''` means the root is the repo
103
+ // top, which is the correct answer for a project laid out with no source root
104
+ // at all — treating that as "no root" silently dropped every flat-layout import.
105
+ if (root === null) return { unresolved: spec };
106
+ for (const candidate of candidatesFor(spec, root, { static: meta.static })) {
107
+ if (has(candidate)) return { path: candidate };
108
+ }
109
+ // Nothing in the repo matched. The JDK and every third-party library land
110
+ // here, and staying `unresolved` is the honest answer: inventing a file would
111
+ // put a node in the graph that does not exist. scan.js reports these by name
112
+ // so the reader knows exactly what the analyzer could not place.
38
113
  return { unresolved: spec };
39
114
  }
@@ -1,4 +1,18 @@
1
1
  // Rust analyzer
2
+ //
3
+ // Rust has no `import` in the Java sense: a crate is a module tree, and `use`
4
+ // names a path *into* that tree. The mapping is still real, though, and it is
5
+ // what gives a Rust repo a graph:
6
+ //
7
+ // crate root src/lib.rs, src/main.rs, or crates/<name>/src/lib.rs
8
+ // module `a` src/a.rs or src/a/mod.rs
9
+ // `crate::a::T` T is declared in src/a.rs (or src/a/mod.rs)
10
+ //
11
+ // So a `use` resolves to the *module file* that owns the item, and the leading
12
+ // `crate` is just the crate root. `self::` and `super::` are relative to the
13
+ // current file's module directory. Anything outside the crate — `std`, a
14
+ // dependency from Cargo.toml — stays unresolved, because inventing a file would
15
+ // be a node in the graph that does not exist.
2
16
  import { blankComments, lineCounter, uniqueBy } from '../util.js';
3
17
 
4
18
  export const extensions = ['.rs'];
@@ -8,7 +22,9 @@ export function analyze(source, path) {
8
22
  const lineAt = lineCounter(clean);
9
23
 
10
24
  const imports = [];
11
- for (const m of clean.matchAll(/\b(?:use|mod)\s+([a-zA-Z0-9_:]+)/g)) {
25
+ // `use foo::bar;` and `mod foo;` are both dependencies on a file, and both are
26
+ // worth an edge. `pub use` is a re-export — still a real dependency.
27
+ for (const m of clean.matchAll(/\b(?:pub\s+)?(?:use|mod)\s+([a-zA-Z0-9_:]+)/g)) {
12
28
  imports.push({ spec: m[1], line: lineAt(m.index) });
13
29
  }
14
30
 
@@ -37,6 +53,92 @@ export function analyze(source, path) {
37
53
  };
38
54
  }
39
55
 
40
- export function resolveImport(spec, fromPath, has, context = {}) {
56
+ const dirOfPath = (p) => {
57
+ const at = String(p).lastIndexOf('/');
58
+ return at < 0 ? '' : String(p).slice(0, at + 1);
59
+ };
60
+
61
+ // The crate root: the `src/` directory, wherever it sits. `src/lib.rs` → `src/`;
62
+ // `crates/foo/src/main.rs` → `crates/foo/src/`. A crate with no `src/` (a flat
63
+ // `lib.rs` in the repo root) is rooted at that file's own directory, which is the
64
+ // same rule with one fewer component to find.
65
+ export function crateRootFor(fromPath) {
66
+ const normalized = String(fromPath).replace(/\\/g, '/');
67
+ const segments = normalized.split('/');
68
+ const at = segments.lastIndexOf('src');
69
+ if (at < 0) return dirOfPath(normalized);
70
+ return segments.slice(0, at + 1).join('/') + '/';
71
+ }
72
+
73
+ // The directory that holds the *children* of the module this file defines.
74
+ //
75
+ // This is not simply the dirname, and getting it wrong breaks every `self::`.
76
+ // src/lib.rs → `src/` the crate root's children sit in src/
77
+ // src/a/b.rs → `src/a/b/` the module is `a::b`, so `self::x` is a/b/x.rs
78
+ // src/a/b/mod.rs → `src/a/b/` the same module, declared the directory way
79
+ //
80
+ // The lib/main special case is the one that is easy to miss: they are named
81
+ // after the crate, not after their module, so stripping `.rs` from `src/lib.rs`
82
+ // would invent a module called `lib`.
83
+ export function moduleDirFor(fromPath) {
84
+ const normalized = String(fromPath).replace(/\\/g, '/');
85
+ const dir = dirOfPath(normalized);
86
+ const name = normalized.slice(dir.length);
87
+ if (name === 'mod.rs' || name === 'lib.rs' || name === 'main.rs') return dir;
88
+ return normalized.replace(/\.rs$/, '') + '/';
89
+ }
90
+
91
+ // A module named by a path is either `path.rs` or `path/mod.rs`. Both are
92
+ // idiomatic Rust, so both are tried and the first that exists wins.
93
+ function moduleCandidates(path) {
94
+ return [path + '.rs', path + '/mod.rs'];
95
+ }
96
+
97
+ export function resolveImport(spec, fromPath, has, context = {}, meta = {}) {
98
+ const from = String(fromPath).replace(/\\/g, '/');
99
+ const raw = String(spec);
100
+ // `::foo::Bar` is a 2018 absolute path — another crate, not this one.
101
+ if (raw.startsWith('::')) return { unresolved: spec };
102
+ const segments = raw.split('::').filter(Boolean);
103
+ if (!segments.length) return { unresolved: spec };
104
+
105
+ // Where the path starts. `crate::` is the crate root; `self::` and a bare
106
+ // `mod foo;` are the current file's own module; `super::` is one level up.
107
+ const head = segments[0];
108
+ let base;
109
+ let rest;
110
+ if (head === 'crate') {
111
+ base = crateRootFor(from);
112
+ rest = segments.slice(1);
113
+ } else if (head === 'super') {
114
+ // `dirOfPath` already ends in a separator, so do not add another — `src/a//`
115
+ // never matches a real path, and the miss would be silent.
116
+ base = dirOfPath(moduleDirFor(from).replace(/\/$/, ''));
117
+ rest = segments.slice(1);
118
+ } else if (head === 'self') {
119
+ base = moduleDirFor(from);
120
+ rest = segments.slice(1);
121
+ } else {
122
+ // A bare `mod foo;` declares a module beside the current one. A bare
123
+ // `use foo::Bar;` means an *external crate* in Rust 2018, so it will not be
124
+ // found here and stays unresolved — which is the right answer for
125
+ // `serde::Serialize` and every other dependency.
126
+ base = moduleDirFor(from);
127
+ rest = segments;
128
+ }
129
+ if (!rest.length) return { unresolved: spec };
130
+
131
+ // `crate::a::b::T` means T is declared *in* the module `a::b`, so the answer is
132
+ // the module file for the longest prefix that exists. Trying the deepest path
133
+ // first costs one lookup and covers the flat `mod a;` → `a.rs` case.
134
+ for (let end = rest.length; end > 0; end -= 1) {
135
+ const candidate = base + rest.slice(0, end).join('/');
136
+ for (const file of moduleCandidates(candidate)) {
137
+ if (has(file)) return { path: file };
138
+ }
139
+ }
140
+
141
+ // `std`, `serde`, anything from Cargo.toml: outside this crate. Unresolved is
142
+ // the honest answer, and scan.js reports these by name.
41
143
  return { unresolved: spec };
42
144
  }
@@ -126,8 +126,31 @@ export async function scanRepo(source, options = {}) {
126
126
  }
127
127
  const findByName = (n) => nameIndex.get(n) || null;
128
128
 
129
+ // Find a directory whose path *ends with* a run of segments. This is what C#
130
+ // needs and Java does not: a C# file's folder mirrors the namespace, but the
131
+ // root namespace (the company or project name) is normally not a directory,
132
+ // so `namespace Acme.Services` lives in `src/Services/`. Matching on a path
133
+ // suffix is the only thing that finds it — stripping the whole namespace, the
134
+ // Java approach, matches nothing and silently drops every import.
135
+ //
136
+ // Keyed by the last three segments, which is enough to tell
137
+ // `Acme/Models/Entities` apart from a bare `Entities` elsewhere while staying
138
+ // O(1) per lookup. A collision at the shortest key keeps the first match, and
139
+ // callers query longest-first, so the most specific answer is tried first.
140
+ const dirByTail = new Map();
141
+ for (const d of dirSet) {
142
+ if (!d) continue;
143
+ const segments = d.split('/');
144
+ for (let take = 1; take <= 3 && take <= segments.length; take += 1) {
145
+ const key = segments.slice(segments.length - take).join('/');
146
+ if (!dirByTail.has(key)) dirByTail.set(key, d);
147
+ }
148
+ }
149
+ const findDirEndingWith = (suffix) => (suffix ? dirByTail.get(suffix) || null : null);
150
+
129
151
  const context = {
130
152
  hasDir,
153
+ findDirEndingWith,
131
154
  findByName,
132
155
  modulePath: await readModulePath(source),
133
156
  tsPaths: await readTsConfigPaths(source),
@@ -212,15 +235,20 @@ export async function scanRepo(source, options = {}) {
212
235
  const tally = { total: 0, internal: 0, external: 0, unresolved: 0 };
213
236
  const unresolvedSpecs = new Map(); // spec -> { count, from }
214
237
 
215
- // Go resolves an import to a *directory*, and every .go file in it is a
238
+ // A resolver may answer with a *directory* instead of a file: Go's import is a
239
+ // package, and a C# `using Acme.Models;` names a namespace whose classes we
240
+ // cannot know from the import alone. Every file in that directory is a
216
241
  // target. Indexed once here rather than scanned per import — a Go repo with
217
242
  // 1,000 files and 8,000 imports was doing eight million comparisons for it.
218
- const goFilesByDir = new Map();
243
+ //
244
+ // Language-neutral on purpose: this used to be `goFilesByDir`, built only from
245
+ // Go files, which meant no other language could use the mechanism that already
246
+ // existed for exactly this problem.
247
+ const filesByDir = new Map();
219
248
  for (const f of files) {
220
- if (f.lang !== 'go') continue;
221
- const bucket = goFilesByDir.get(f.dir);
249
+ const bucket = filesByDir.get(f.dir);
222
250
  if (bucket) bucket.push(f.path);
223
- else goFilesByDir.set(f.dir, [f.path]);
251
+ else filesByDir.set(f.dir, [f.path]);
224
252
  }
225
253
 
226
254
  for (const file of files) {
@@ -233,7 +261,10 @@ export async function scanRepo(source, options = {}) {
233
261
  addEdge(edges, edgeKeys, file.path, res.path, 'imports', imp.symbols);
234
262
  } else if (res.packageDir) {
235
263
  tally.internal++;
236
- for (const target of goFilesByDir.get(res.packageDir) || []) {
264
+ // A directory import means "everything in there". addEdge de-duplicates,
265
+ // so a package with fifty files yields fifty honest edges rather than
266
+ // one edge standing in for all of them.
267
+ for (const target of filesByDir.get(res.packageDir) || []) {
237
268
  addEdge(edges, edgeKeys, file.path, target, 'imports', imp.symbols);
238
269
  }
239
270
  } else if (res.external) {