codebase-onboarder 0.3.1 → 0.4.1

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,19 +1,40 @@
1
1
  // C# analyzer
2
- import { blankComments, lineCounter, uniqueBy } from '../util.js';
2
+ //
3
+ // C# is namespace-rooted exactly as Java is package-rooted: a file's path is its
4
+ // namespace spelled with slashes, under a project root. The difference that
5
+ // matters is the import. Java's `import a.b.C;` names a *class*, so one path
6
+ // answers it; C#'s `using A.B;` names a *namespace*, and the classes inside it
7
+ // are not knowable from the import string. So a namespace import resolves to a
8
+ // directory and the scanner links to every file in it — the same shape Go uses
9
+ // for packages.
10
+ import { blankComments, lineCounter } from '../util.js';
3
11
 
4
12
  export const extensions = ['.cs'];
5
13
 
14
+ const NAMESPACE_RE = /^\s*namespace\s+([A-Za-z0-9_.]+)\s*[;{]/m;
15
+
6
16
  export function analyze(source, path) {
7
17
  const clean = blankComments(source);
8
18
  const lineAt = lineCounter(clean);
9
19
 
20
+ // The file's own namespace. Captured first because every import is resolved
21
+ // relative to it — this declaration is the only place the root is written down.
22
+ // Block-scoped namespaces (`namespace A.B { }`) are the norm, and the regex
23
+ // accepts both the `;` and `{` forms.
24
+ const namespace = (clean.match(NAMESPACE_RE) || [])[1] || '';
25
+
10
26
  const imports = [];
11
- for (const m of clean.matchAll(/\busing\s+([a-zA-Z0-9_.]+)\s*;/g)) {
12
- imports.push({ spec: m[1], line: lineAt(m.index) });
27
+ // `using static A.B.C;` names a type (and optionally a member of it); plain
28
+ // `using A.B;` names a namespace. The distinction decides whether we look for
29
+ // a file or a directory.
30
+ for (const m of clean.matchAll(/\busing\s+(static\s+)?([A-Za-z0-9_.]+)\s*;/g)) {
31
+ imports.push({ spec: m[2], static: !!m[1], namespace, line: lineAt(m.index) });
13
32
  }
33
+ // `using X = Y;` aliases and global usings are out of scope; a spec that fails
34
+ // to resolve is reported as unresolved rather than guessed at.
14
35
 
15
36
  const classes = [];
16
- for (const m of clean.matchAll(/(\[[^\]]+\]\s*)*\b(?:public\s+|private\s+|protected\s+|internal\s+)*(?:abstract\s+|sealed\s+)?(?:class|interface|struct|record|enum)\s+([a-zA-Z0-9_]+)/g)) {
37
+ for (const m of clean.matchAll(/(\[[^\]]+\]\s*)*\b(?:public\s+|private\s+|protected\s+|internal\s+)*(?:abstract\s+|sealed\s+|static\s+|partial\s+)*(?:class|interface|struct|record|enum)\s+([a-zA-Z0-9_]+)/g)) {
17
38
  const attributes = [];
18
39
  if (m[1]) {
19
40
  for (const am of m[1].matchAll(/\[([A-Za-z0-9_]+)/g)) {
@@ -30,10 +51,64 @@ export function analyze(source, path) {
30
51
  exports: classes.map(c => ({ name: c.name, kind: 'class' })),
31
52
  functions: [],
32
53
  classes,
33
- hasMain
54
+ hasMain,
55
+ namespace,
34
56
  };
35
57
  }
36
58
 
37
- export function resolveImport(spec, fromPath, has, context = {}) {
59
+ // A C# `using` is resolved by *path suffix*, not by a root prefix. See
60
+ // `resolveImport` below for why that distinction matters — it is the whole
61
+ // difference between C# and Java, and getting it backwards drops every import.
62
+ //
63
+ // `sourceRootFor` is kept and exported because it is the answer for the layouts
64
+ // where a project *does* mirror the full namespace (`Acme/Services/User.cs`).
65
+ // It is a fallback, not the main path.
66
+ export function sourceRootFor(fromPath, namespace) {
67
+ if (!namespace) return null;
68
+ const suffix = namespace.replace(/\./g, '/');
69
+ const normalized = String(fromPath).replace(/\\/g, '/');
70
+ const nested = normalized.lastIndexOf('/' + suffix + '/');
71
+ if (nested >= 0) return normalized.slice(0, nested + 1);
72
+ if (normalized === suffix || normalized.startsWith(suffix + '/')) return '';
73
+ return null;
74
+ }
75
+
76
+ export function resolveImport(spec, fromPath, has, context = {}, meta = {}) {
77
+ const parts = spec.split('.').filter(Boolean);
78
+ const findDir = (segments) => (segments.length ? context.findDirEndingWith?.(segments.join('/')) : null);
79
+ if (!parts.length) return { unresolved: spec };
80
+
81
+ // A C# `using` is ambiguous from the string alone: in `using Acme.Models;`,
82
+ // `Models` might be a namespace (link its directory) or a type (link the
83
+ // file). Both readings are tried, most specific first.
84
+ //
85
+ // The other half of the puzzle is that C# resolves by *path suffix*, not by a
86
+ // root prefix the way Java does. `namespace Acme.Services` conventionally
87
+ // lives in `src/Services/`: the folders mirror the namespace, but the leading
88
+ // `Acme` — the project or company root — is not a directory. So the namespace
89
+ // segments are matched against the *tail* of a real directory path, which is
90
+ // what `findDirEndingWith` answers. Stripping the whole namespace instead, the
91
+ // Java approach, matches nothing here and silently drops every import.
92
+
93
+ // Reading 1: the last segment is a type. Find the directory holding its
94
+ // namespace, then the file named after it.
95
+ const namespaceParts = parts.slice(0, -1);
96
+ for (let take = namespaceParts.length; take > 0; take -= 1) {
97
+ const dir = findDir(namespaceParts.slice(namespaceParts.length - take));
98
+ if (!dir) continue;
99
+ const exact = dir + '/' + parts[parts.length - 1] + '.cs';
100
+ if (has(exact)) return { path: exact };
101
+ if (meta.static) return { unresolved: spec }; // `using static A.B.C;` is always a type
102
+ }
103
+
104
+ // Reading 2: the whole spec is a namespace, so link its directory.
105
+ for (let take = parts.length; take > 0; take -= 1) {
106
+ const dir = findDir(parts.slice(parts.length - take));
107
+ if (dir) return { packageDir: dir };
108
+ }
109
+
110
+ // The BCL (`System`, `System.Collections.Generic`) and every NuGet package land
111
+ // here. Staying unresolved is the honest answer: a file we invent would be a
112
+ // node in the graph that does not exist.
38
113
  return { unresolved: spec };
39
114
  }
@@ -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) {