@ecoma-io/archkeep 0.15.0 → 0.16.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.
- package/README.md +3 -3
- package/cli.mjs +126 -4
- package/commands.mjs +6 -0
- package/lsp.mjs +15 -2
- package/package.json +6 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +514 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +303 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +9 -2
- package/src/commands/context.mjs +84 -14
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/workspace.mjs +115 -23
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JVM package index — the content-derived `package → project` map every
|
|
3
|
+
* JVM resolution reads through.
|
|
4
|
+
*
|
|
5
|
+
* Java and Kotlin do not require directory = package. javac enforces nothing
|
|
6
|
+
* about where a `.java` file sits relative to its `package` line, and Kotlin's
|
|
7
|
+
* coding conventions state the layout is "recommended", unenforced. A resolver
|
|
8
|
+
* that derived importable names from directory layout would therefore answer
|
|
9
|
+
* confidently about a tree it had misread — the failure mode Python's reader
|
|
10
|
+
* avoids by reading its declared backend layout, and which has no manifest
|
|
11
|
+
* equivalent here to read instead. The declaration lives in the file, so the
|
|
12
|
+
* index reads the file.
|
|
13
|
+
*
|
|
14
|
+
* The index spans BOTH extensions from its first commit. A mixed Java/Kotlin
|
|
15
|
+
* module compiles jointly into one package namespace (one classpath, sources
|
|
16
|
+
* from `src/main/java` and `src/main/kotlin` together), so a `.java` import
|
|
17
|
+
* may reach a package only a `.kt` file declares and the other way round.
|
|
18
|
+
* Two per-extension indexes would disagree exactly there; one index over both
|
|
19
|
+
* is the model the compilers actually implement.
|
|
20
|
+
*
|
|
21
|
+
* One dotted name may be claimed by more than one project — a split package,
|
|
22
|
+
* or two projects that genuinely declare the same package. Like Python's PEP
|
|
23
|
+
* 420 namespace packages (`../python.mjs`'s `resolveModuleName`), ambiguity is
|
|
24
|
+
* not resolved by picking: the deepest matched prefix carries the owner set it
|
|
25
|
+
* was found with, and a caller turns a multi-owner answer into `resolved: null`
|
|
26
|
+
* plus a positioned failure naming the projects. Silence would be the worse
|
|
27
|
+
* direction: an unresolved first-party name classified external is a missed
|
|
28
|
+
* boundary crossing wearing an honest face.
|
|
29
|
+
*
|
|
30
|
+
* Reads are injected (`workspace.filesOf` / `workspace.readFile`) and memoized
|
|
31
|
+
* per workspace object through `perWorkspace`, so a whole-tree run builds the
|
|
32
|
+
* index once no matter how many files ask.
|
|
33
|
+
*
|
|
34
|
+
* An unreadable `.java`/`.kt` source is recorded as a whole-file failure
|
|
35
|
+
* rather than dropped: a file that silently left the index would make every
|
|
36
|
+
* import of a package only it declares classify external — a first-party
|
|
37
|
+
* crossing wearing an external face, with nothing anywhere naming why (the
|
|
38
|
+
* `../dotnet/namespaces.mjs` precedent one family over). The failure list is
|
|
39
|
+
* what the graph resolvers refuse the tree on (#364's posture,
|
|
40
|
+
* `../source-util.mjs`'s `refuseUnreadTree`) and what the CLI funnel merges
|
|
41
|
+
* beside the analyzers' own read failures.
|
|
42
|
+
*/
|
|
43
|
+
import { fileFailure, perWorkspace } from "../source-util.mjs";
|
|
44
|
+
import { maskJavaComments, maskKotlinComments } from "./mask.mjs";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `package` declaration of an already-masked JVM source, with the offset
|
|
48
|
+
* of the name itself.
|
|
49
|
+
*
|
|
50
|
+
* Only the FIRST match counts. JLS §7.3 and the Kotlin grammar both confine
|
|
51
|
+
* the declaration to the compilation unit's header — before imports, before
|
|
52
|
+
* types — so any later `package` line in masked text is text the file holds
|
|
53
|
+
* but not a declaration, and honoring it could only move ownership to a lie.
|
|
54
|
+
* The worst case this parse allows is a spurious record naming text the file
|
|
55
|
+
* really contains, never a missed declaration.
|
|
56
|
+
*
|
|
57
|
+
* A UTF-8 BOM is tolerated the way `parseGoModulePath` tolerates one (#221):
|
|
58
|
+
* an anchored `^package` never matches through a leading `\uFEFF`, and a
|
|
59
|
+
* declaration lost to one byte drops the whole file out of the index — no
|
|
60
|
+
* owners for its package, every import reaching it classified external, a
|
|
61
|
+
* silent hole. The BOM is matched, not stripped, so every offset this parse
|
|
62
|
+
* returns is already an offset into the original text.
|
|
63
|
+
*
|
|
64
|
+
* Semicolon handling: Java requires the `;`; Kotlin ends the header at the
|
|
65
|
+
* newline instead. The declaration therefore terminates EITHER at a
|
|
66
|
+
* semicolon — anything may follow one on the same line (`package p; import
|
|
67
|
+
* q.R;` is legal Java) — OR at end of line. The keyword must be followed by
|
|
68
|
+
* same-line whitespace before the name, which is what keeps a Kotlin file
|
|
69
|
+
* with no package at all from reading its first `import` line as a package
|
|
70
|
+
* named "import".
|
|
71
|
+
*
|
|
72
|
+
* Kotlin backtick-quoted package segments (``package `odd name`.``) are a
|
|
73
|
+
* pinned limit: they do not match, the file contributes no index entry, and
|
|
74
|
+
* imports of its package resolve as external rather than to their project.
|
|
75
|
+
* Documented beside the other unread shapes when the language sections land;
|
|
76
|
+
* prevalence in real trees is negligible.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} maskedText Comment-and-literal-blanked source (same length
|
|
79
|
+
* as the original), so offsets index the original text.
|
|
80
|
+
* @returns {{ name: string, offset: number }|null} `null` for a default-
|
|
81
|
+
* package file, which declares no name the index can carry.
|
|
82
|
+
*/
|
|
83
|
+
export function parseJvmPackageDeclaration(maskedText) {
|
|
84
|
+
// An identifier segment starts with a letter (Unicode, via \p{L}), `_`, or
|
|
85
|
+
// `$`, continues with those plus digits; segments join on optional spaces
|
|
86
|
+
// around the dot, because `com . example` is legal if absurd. The match is
|
|
87
|
+
// anchored to a line start (or behind an optional BOM at position 0) so a
|
|
88
|
+
// mid-line `package` token never reads as the declaration.
|
|
89
|
+
const JVM_PACKAGE_DECLARATION =
|
|
90
|
+
/(?:^\uFEFF?|\n)[ \t]*package[ \t]+([\p{L}_$][\p{L}\p{Nd}_$]*(?:[ \t]*\.[ \t]*[\p{L}_$][\p{L}\p{Nd}_$]*)*)[ \t]*(?:;|(?=[\r\n]|$))/u;
|
|
91
|
+
const match = JVM_PACKAGE_DECLARATION.exec(maskedText);
|
|
92
|
+
if (!match) return null;
|
|
93
|
+
// The mask preserved every byte's place, so the name's offset inside the
|
|
94
|
+
// match, plus where the match starts, is its offset in the original text.
|
|
95
|
+
return {
|
|
96
|
+
name: match[1].replace(/[ \t]*\.[ \t]*/g, "."),
|
|
97
|
+
offset: match.index + match[0].indexOf(match[1]),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const MASK_BY_EXTENSION = {
|
|
102
|
+
".java": maskJavaComments,
|
|
103
|
+
".kts": maskKotlinComments,
|
|
104
|
+
".kt": maskKotlinComments,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The mask for a JVM source file's extension, or `undefined` elsewhere. */
|
|
108
|
+
const maskFor = (file) => MASK_BY_EXTENSION[file.slice(file.lastIndexOf("."))];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build the index: every tracked JVM source's package, attributed by longest
|
|
112
|
+
* project root. Returns the map keyed by exact declared dotted name, each
|
|
113
|
+
* entry listing `{ project, file }` pairs in project order — beside one
|
|
114
|
+
* whole-file failure per JVM source that could not be read: a file dropped
|
|
115
|
+
* from the index silently would make every import of its package classify
|
|
116
|
+
* external, a first-party crossing wearing an external face, with nothing
|
|
117
|
+
* anywhere naming why (`../contract.md`'s I/O law). The same discipline the
|
|
118
|
+
* .NET twin holds for its namespace index (`../dotnet/namespaces.mjs`).
|
|
119
|
+
*
|
|
120
|
+
* @param {object} workspace `{ projects, filesOf(name), readFile(path) }`
|
|
121
|
+
* @returns {{ byName: Map<string, { project: string, file: string }[]>,
|
|
122
|
+
* failures: { sourceFile: string, line: null, column: null, reason: string }[] }}
|
|
123
|
+
*/
|
|
124
|
+
function buildJvmPackageIndex(workspace) {
|
|
125
|
+
const byName = new Map();
|
|
126
|
+
const failures = [];
|
|
127
|
+
for (const project of workspace.projects) {
|
|
128
|
+
for (const file of workspace.filesOf(project.name)) {
|
|
129
|
+
const mask = maskFor(file);
|
|
130
|
+
if (!mask) continue;
|
|
131
|
+
const text = workspace.readFile(file);
|
|
132
|
+
if (text === null || text === undefined) {
|
|
133
|
+
failures.push(fileFailure(file, "JVM source could not be read for the package index"));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const declared = parseJvmPackageDeclaration(mask(text));
|
|
137
|
+
if (!declared) continue;
|
|
138
|
+
const owners = byName.get(declared.name) ?? [];
|
|
139
|
+
owners.push({ project: project.name, file });
|
|
140
|
+
byName.set(declared.name, owners);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { byName, failures };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The workspace's package index, built once per workspace object. Every JVM
|
|
148
|
+
* consumer — the analyzers, the graph resolvers — reads resolution through
|
|
149
|
+
* this one map, so the layers can never disagree about who owns a name.
|
|
150
|
+
*/
|
|
151
|
+
export const jvmPackageIndex = perWorkspace(buildJvmPackageIndex);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whole-file failures for every JVM source the index could not read — the
|
|
155
|
+
* funnel `../../commands/context.mjs` merges beside the manifest failures, so
|
|
156
|
+
* an unreadable source refuses the verdict (exit 3) instead of quietly
|
|
157
|
+
* degrading every importer of its packages to external — and the graph
|
|
158
|
+
* resolvers' refusal input (#364's posture, `refuseUnreadTree`).
|
|
159
|
+
*
|
|
160
|
+
* @param {object} workspace
|
|
161
|
+
* @returns {{ sourceFile: string, line: null, column: null, reason: string }[]}
|
|
162
|
+
*/
|
|
163
|
+
export function jvmIndexFailures(workspace) {
|
|
164
|
+
return jvmPackageIndex(workspace).failures;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Longest-prefix resolution over the index — the single answer both layers
|
|
169
|
+
* read a specifier with, matching Go's `resolveGoModule` discipline: walk
|
|
170
|
+
* from the full name toward its head, stop at the first (deepest) prefix the
|
|
171
|
+
* index knows, and report the owner set found there. A shallower match under
|
|
172
|
+
* a deeper hit is invisible by construction; a nested-package project is a
|
|
173
|
+
* different project, and a first-shallow-match answer would name its parent.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} specifier Dotted name as written after the `import`
|
|
176
|
+
* keyword — the importable name, which for a single-type or member import
|
|
177
|
+
* is everything before the imported member (stripped by the caller).
|
|
178
|
+
* @param {Map<string, { project: string, file: string }[]>} index As built
|
|
179
|
+
* by `jvmPackageIndex`.
|
|
180
|
+
* @returns {{ owners: { project: string, file: string }[], prefix: string }}
|
|
181
|
+
* | null `null` names no known prefix — the specifier is outside every
|
|
182
|
+
* tracked project (external, or first-party code this run cannot see).
|
|
183
|
+
*/
|
|
184
|
+
export function resolveJvmPackagePrefix(specifier, index) {
|
|
185
|
+
const parts = specifier.split(".");
|
|
186
|
+
for (let depth = parts.length; depth >= 1; depth--) {
|
|
187
|
+
const prefix = parts.slice(0, depth).join(".");
|
|
188
|
+
const owners = index.get(prefix);
|
|
189
|
+
if (owners) return { owners, prefix };
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The owning project of a resolved prefix, when exactly one project claims
|
|
196
|
+
* the matched name.
|
|
197
|
+
*
|
|
198
|
+
* @param {{ owners: { project: string }[], prefix: string }} resolution
|
|
199
|
+
* @returns {{ target: string, ambiguous?: undefined } |
|
|
200
|
+
* { target: null, ambiguous: true, projects: string[] }} A single
|
|
201
|
+
* target when the owners agree; otherwise every distinct claimant, for the
|
|
202
|
+
* caller's failure record.
|
|
203
|
+
*/
|
|
204
|
+
export function projectOfResolution(resolution) {
|
|
205
|
+
const projects = [...new Set(resolution.owners.map((owner) => owner.project))];
|
|
206
|
+
return projects.length === 1
|
|
207
|
+
? { target: projects[0] }
|
|
208
|
+
: { target: null, ambiguous: true, projects };
|
|
209
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JVM specifier resolution — turning an imported dotted name into the record
|
|
3
|
+
* the analysis contract carries (`../contract.md`): a project target, or an
|
|
4
|
+
* external classification, or an ambiguity the caller reports rather than
|
|
5
|
+
* guesses past.
|
|
6
|
+
*
|
|
7
|
+
* The classification order is the language's, not a preference:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Workspace packages first.** A name (or a prefix of it) some tracked
|
|
10
|
+
* project declares resolves to that project through
|
|
11
|
+
* `resolveJvmPackagePrefix`'s longest-prefix walk. First-party beats every
|
|
12
|
+
* later answer: misreading first-party as external is the silent direction,
|
|
13
|
+
* and no rule can see a crossing the resolver called a library.
|
|
14
|
+
* 2. **Default imports by table.** Each dialect auto-imports a fixed package
|
|
15
|
+
* set (Java: `java.lang.*`; Kotlin adds its nine stdlib packages plus the
|
|
16
|
+
* platform sets). An explicit import of one of these names is still legal
|
|
17
|
+
* and still external — the tables make that classification explicit and
|
|
18
|
+
* testable instead of incidental, and they change only across language
|
|
19
|
+
* releases. Cited per table below; never extended by guesswork.
|
|
20
|
+
* 3. **Everything else is external**, with the whole written name standing in
|
|
21
|
+
* as `packageName` — where a group ends and an artifact begins inside
|
|
22
|
+
* `org.apache.commons.lang3` is not statically knowable (only the registry
|
|
23
|
+
* knows), so the full name stands in exactly as Go's resolver lets the
|
|
24
|
+
* whole module-prefixed path stand in. A `bannedExternalImports` glob
|
|
25
|
+
* matches it the same way.
|
|
26
|
+
*
|
|
27
|
+
* What this module deliberately does NOT do: read files, hold caches, or know
|
|
28
|
+
* which extension wrote the specifier. The index comes in as an argument; the
|
|
29
|
+
* only language-aware input is the defaults table's name. That keeps the
|
|
30
|
+
* resolution rules identical for `.java`, `.kt`, and any future dotted-name
|
|
31
|
+
* frontend, which is the property a second frontend proves rather than
|
|
32
|
+
* promises.
|
|
33
|
+
*/
|
|
34
|
+
import { resolveJvmPackagePrefix } from "./packages.mjs";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Java's single default-import package root (JLS §7.5.5: `java.lang` is
|
|
38
|
+
* automatically imported "as if by an import declaration" in every
|
|
39
|
+
* compilation unit). Sub-packages (`java.util`) are NOT included — they need
|
|
40
|
+
* their own explicit imports, which is why an explicit `import java.util.List`
|
|
41
|
+
* classifies as ordinary external rather than by this table.
|
|
42
|
+
*/
|
|
43
|
+
export const JAVA_DEFAULT_IMPORT_ROOTS = Object.freeze(["java.lang"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Kotlin's default imports (kotlinlang.org, "Default imports", page dated
|
|
47
|
+
* 2026-07): nine stdlib roots always, `java.lang` and `kotlin.jvm` on the JVM
|
|
48
|
+
* target, `kotlin.js` on JS. The union is what an explicit import may legally
|
|
49
|
+
* restate; the table exists for the same reason Java's does — explicit,
|
|
50
|
+
* testable classification of names the compiler brings in unasked.
|
|
51
|
+
*/
|
|
52
|
+
export const KOTLIN_DEFAULT_IMPORT_ROOTS = Object.freeze([
|
|
53
|
+
"kotlin",
|
|
54
|
+
"kotlin.annotation",
|
|
55
|
+
"kotlin.collections",
|
|
56
|
+
"kotlin.comparisons",
|
|
57
|
+
"kotlin.io",
|
|
58
|
+
"kotlin.ranges",
|
|
59
|
+
"kotlin.sequences",
|
|
60
|
+
"kotlin.text",
|
|
61
|
+
"kotlin.math",
|
|
62
|
+
"java.lang",
|
|
63
|
+
"kotlin.jvm",
|
|
64
|
+
"kotlin.js",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const DEFAULT_IMPORT_ROOTS_BY_LANGUAGE = {
|
|
68
|
+
java: JAVA_DEFAULT_IMPORT_ROOTS,
|
|
69
|
+
kotlin: KOTLIN_DEFAULT_IMPORT_ROOTS,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The default-import roots a language's files carry, or `[]` when unknown. */
|
|
73
|
+
export const defaultImportRootsFor = (language) => DEFAULT_IMPORT_ROOTS_BY_LANGUAGE[language] ?? [];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True when `specifier` falls under one of `roots`: equal to a root or a dot-
|
|
77
|
+
* delimited segment beneath it. Segment-delimited on purpose — `java.langx`
|
|
78
|
+
* must not match the `java.lang` root, and a plain prefix test would say it
|
|
79
|
+
* does.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} specifier
|
|
82
|
+
* @param {string[]} roots
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export const underAnyRoot = (specifier, roots) =>
|
|
86
|
+
roots.some((root) => specifier === root || specifier.startsWith(`${root}.`));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Classify one imported dotted name.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} importableName Everything the resolution can see: for
|
|
92
|
+
* `import a.b.C` the name `a.b.C`; for `import a.b.*` the package `a.b`;
|
|
93
|
+
* for `import a.b.C as D` still `a.b.C` (the alias is local syntax).
|
|
94
|
+
* @param {{ language: string }} dialect Which defaults table applies.
|
|
95
|
+
* @param {Map<string, { project: string, file: string }[]>} index As built
|
|
96
|
+
* by `./packages.mjs`'s `jvmPackageIndex`.
|
|
97
|
+
* @returns {JvmResolution} A project target; or an ambiguity the caller turns
|
|
98
|
+
* into `resolved: null` + a positioned failure naming the projects; or an
|
|
99
|
+
* external classification with `packageName` = the full written name.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {object} JvmResolution
|
|
104
|
+
* @property {string|null} target The owning project, null when external or
|
|
105
|
+
* ambiguous.
|
|
106
|
+
* @property {boolean} external True when no tracked project claims any
|
|
107
|
+
* prefix of the name.
|
|
108
|
+
* @property {string|null} packageName The full written name for externals;
|
|
109
|
+
* null otherwise.
|
|
110
|
+
* @property {string[]} [ambiguous] Every distinct claimant of the deepest
|
|
111
|
+
* matched prefix, when several projects declare it.
|
|
112
|
+
* @property {string} [matchedPrefix] The deepest prefix the ambiguity was
|
|
113
|
+
* found at.
|
|
114
|
+
* @property {boolean} [byDefaultImport] Externals only: true when the name
|
|
115
|
+
* falls under the dialect's default-import roots rather than reaching a
|
|
116
|
+
* registry anyone depends on explicitly.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveJvmSpecifier(importableName, dialect, index) {
|
|
119
|
+
const matched = resolveJvmPackagePrefix(importableName, index);
|
|
120
|
+
if (matched) {
|
|
121
|
+
const projects = [...new Set(matched.owners.map((owner) => owner.project))];
|
|
122
|
+
if (projects.length === 1) {
|
|
123
|
+
return { target: projects[0], external: false, packageName: null };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
target: null,
|
|
127
|
+
external: false,
|
|
128
|
+
packageName: null,
|
|
129
|
+
ambiguous: projects,
|
|
130
|
+
matchedPrefix: matched.prefix,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
target: null,
|
|
135
|
+
external: true,
|
|
136
|
+
packageName: importableName,
|
|
137
|
+
byDefaultImport: underAnyRoot(importableName, defaultImportRootsFor(dialect.language)),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kotlin analyzer — header-region extraction over comment-and-literal-masked
|
|
3
|
+
* text, sharing the JVM core with `./java.mjs` and owning only what Kotlin's
|
|
4
|
+
* grammar makes different (`docs/adr/0005-jvm-language-integration.md`).
|
|
5
|
+
*
|
|
6
|
+
* The Kotlin grammar fixes an equivalent header:
|
|
7
|
+
* `shebang? NL* fileAnnotation* packageHeader importList`, so extraction stays
|
|
8
|
+
* line-anchored over masked text exactly as Java's is. What differs:
|
|
9
|
+
*
|
|
10
|
+
* - **Three import forms**: single (`import a.b.C`), on-demand
|
|
11
|
+
* (`import a.b.*`), and aliased (`import a.b.C as D`) — the alias is local
|
|
12
|
+
* syntax and resolves nothing differently.
|
|
13
|
+
* - **No statement terminator to rely on.** A newline ends the import (a
|
|
14
|
+
* semicolon may, but Kotlin omits it), so the match ends at the line break;
|
|
15
|
+
a name must still start on the import's own line, which pins the multi-line
|
|
16
|
+
* limit the same way Java's does.
|
|
17
|
+
* - **Backtick-quoted segments are identifiers** (``import a.`when`.B``) and
|
|
18
|
+
* are read as such; the PACKAGE index does not carry backticked segments
|
|
19
|
+
* (see `./jvm/packages.mjs`'s pinned limit), so an import THROUGH one
|
|
20
|
+
* resolves by its plain prefix or classifies external.
|
|
21
|
+
* - **Shebang** lines (`.kts` scripts) and `@file:` annotations precede the
|
|
22
|
+
* header; neither matches the anchored forms.
|
|
23
|
+
*
|
|
24
|
+
* Everything downstream is the shared answer: `kind` always `"static"`,
|
|
25
|
+
* `spelling.path` always false and `spelling.namesOnly` always true (a package
|
|
26
|
+
* name is a name, never a path — #376), `spelling.relative` true exactly when the
|
|
27
|
+
* import resolved into its own project, resolution through
|
|
28
|
+
* `./jvm/resolve.mjs` with Kotlin's default-import table, and graph edges via
|
|
29
|
+
* the same contract as every other language. There is no dynamic import, no
|
|
30
|
+
* type-only form, and no re-export syntax to model.
|
|
31
|
+
*/
|
|
32
|
+
import { maskKotlinComments } from "./jvm/mask.mjs";
|
|
33
|
+
import { jvmPackageIndex } from "./jvm/packages.mjs";
|
|
34
|
+
import { resolveJvmSpecifier } from "./jvm/resolve.mjs";
|
|
35
|
+
import {
|
|
36
|
+
emptyResult,
|
|
37
|
+
fileFailure,
|
|
38
|
+
positionAt,
|
|
39
|
+
projectOwning,
|
|
40
|
+
refuseUnreadTree,
|
|
41
|
+
} from "./source-util.mjs";
|
|
42
|
+
|
|
43
|
+
/** One identifier segment: backtick-quoted (any non-backtick content) or a plain identifier. */
|
|
44
|
+
const KOTLIN_SEGMENT = String.raw`(?:` + "`[^`\\n]*`" + String.raw`|[\p{L}_$][\p{L}\p{Nd}_$]*)`;
|
|
45
|
+
|
|
46
|
+
// Anchored to a line head — through a leading UTF-8 BOM, matched rather than
|
|
47
|
+
// stripped so offsets keep indexing the bytes on disk (#221's lesson, the same
|
|
48
|
+
// anchor `./jvm/packages.mjs`'s package declaration and `./csharp.mjs` hold) —
|
|
49
|
+
// or behind a semicolon; the name must start on the import's own line; the
|
|
50
|
+
// optional alias is captured only to keep it out of the specifier. The
|
|
51
|
+
// terminator is a lookahead over `\r` as well as `\n` (#406): on a CRLF file
|
|
52
|
+
// the lookahead must succeed at the line break, or every import in the file
|
|
53
|
+
// is dropped, byte-for-byte like a file with none.
|
|
54
|
+
const KOTLIN_IMPORT = new RegExp(
|
|
55
|
+
String.raw`(?:^\uFEFF?|[\n;])[ \t]*(?:import[ \t]+)(` +
|
|
56
|
+
KOTLIN_SEGMENT +
|
|
57
|
+
String.raw`(?:\.` +
|
|
58
|
+
KOTLIN_SEGMENT +
|
|
59
|
+
String.raw`)*(?:\.\*)?)` +
|
|
60
|
+
String.raw`(?:[ \t]+as[ \t]+[\p{L}_$][\p{L}\p{Nd}_$]*)?[ \t]*(?=[\r\n;}]|$)`,
|
|
61
|
+
"gu",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every import in a `.kt`/`.kts` file, in source order and WITHOUT
|
|
66
|
+
* deduplication. Offsets index the ORIGINAL text; the mask preserved length.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} kotlinText Raw file contents.
|
|
69
|
+
* @returns {{ specifier: string, importableName: string, offset: number }[]}
|
|
70
|
+
*/
|
|
71
|
+
export function parseKotlinImportSites(kotlinText) {
|
|
72
|
+
// A shebang needs no handling of its own: `#!…` cannot anchor an import
|
|
73
|
+
// match (`import` must follow a line head, `;`, or newline), and masking
|
|
74
|
+
// runs before anything reads the text anyway.
|
|
75
|
+
const source = maskKotlinComments(kotlinText);
|
|
76
|
+
const sites = [];
|
|
77
|
+
for (const match of source.matchAll(KOTLIN_IMPORT)) {
|
|
78
|
+
const name = match[1];
|
|
79
|
+
sites.push({
|
|
80
|
+
specifier: name,
|
|
81
|
+
importableName: importableNameOf(name),
|
|
82
|
+
offset: match.index + match[0].indexOf(name),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return sites.sort((a, b) => a.offset - b.offset);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The dotted name resolution walks: the on-demand form drops its trailing
|
|
90
|
+
* `.*`; everything else resolves whole, longest declared prefix winning.
|
|
91
|
+
* Aliases never reach this function — they are stripped by the match itself.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} name The dotted name as written.
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
const importableNameOf = (name) => (name.endsWith(".*") ? name.slice(0, -2) : name);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Analyzes one `.kt`/`.kts` file. Ambiguity resolves to null WITH a
|
|
100
|
+
* positioned failure naming every claimant, exactly as Java's does — the
|
|
101
|
+
* split-package rule cannot know which compiler unit order would win, and
|
|
102
|
+
* neither will this reader pretend to.
|
|
103
|
+
*
|
|
104
|
+
* The package index arrives through `jvmPackageIndex` — already memoized per
|
|
105
|
+
* workspace object — so one whole-tree run builds it once however many files
|
|
106
|
+
* ask, and the graph resolver below reads the same map through the same memo.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ sourceFile: string, text: string, workspace: object }} request
|
|
109
|
+
* @returns {{ imports: object[], failures: object[] }}
|
|
110
|
+
*/
|
|
111
|
+
export function analyzeKotlin({ sourceFile, text, workspace }) {
|
|
112
|
+
const result = emptyResult();
|
|
113
|
+
try {
|
|
114
|
+
const { byName: index } = jvmPackageIndex(workspace);
|
|
115
|
+
const owner = projectOwning(workspace.projects, sourceFile);
|
|
116
|
+
for (const site of parseKotlinImportSites(text)) {
|
|
117
|
+
const { line, column } = positionAt(text, site.offset);
|
|
118
|
+
const resolved = resolveJvmSpecifier(site.importableName, { language: "kotlin" }, index);
|
|
119
|
+
let resolution;
|
|
120
|
+
if (resolved.external) {
|
|
121
|
+
resolution = {
|
|
122
|
+
target: null,
|
|
123
|
+
file: null,
|
|
124
|
+
external: true,
|
|
125
|
+
packageName: site.importableName,
|
|
126
|
+
};
|
|
127
|
+
} else if (resolved.ambiguous) {
|
|
128
|
+
resolution = null;
|
|
129
|
+
result.failures.push({
|
|
130
|
+
sourceFile,
|
|
131
|
+
line,
|
|
132
|
+
column,
|
|
133
|
+
reason:
|
|
134
|
+
`'${resolved.matchedPrefix}' is declared by more than one project ` +
|
|
135
|
+
`(${resolved.ambiguous.join(", ")}) — the compilers pick by classpath order, ` +
|
|
136
|
+
`which this static reader does not model`,
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
resolution = { target: resolved.target, file: null, external: false, packageName: null };
|
|
140
|
+
}
|
|
141
|
+
const target = resolution?.target ?? null;
|
|
142
|
+
result.imports.push({
|
|
143
|
+
sourceFile,
|
|
144
|
+
line,
|
|
145
|
+
column,
|
|
146
|
+
specifier: site.specifier,
|
|
147
|
+
kind: "static",
|
|
148
|
+
spelling: {
|
|
149
|
+
path: false,
|
|
150
|
+
relative: target !== null && owner !== null && target === owner.name,
|
|
151
|
+
namesOnly: true,
|
|
152
|
+
},
|
|
153
|
+
resolved: resolution,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
result.failures.push(
|
|
158
|
+
fileFailure(sourceFile, `Kotlin analysis failed: ${cause?.message ?? cause}`),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Static edges between JVM projects derived from written Kotlin imports —
|
|
166
|
+
* the same source-truth track `resolveJavaDependencies` runs, one namespace
|
|
167
|
+
* over. Takes ONE workspace-shaped object for the same reason it does: the
|
|
168
|
+
* package index is memoized on that object, so the caller's one object —
|
|
169
|
+
* shared with `analyzeKotlin`, the Java resolver and the manifest resolvers —
|
|
170
|
+
* is what makes the index build once per run (#363).
|
|
171
|
+
*
|
|
172
|
+
* An unreadable `.java`/`.kt` source refuses the whole graph (#364's posture
|
|
173
|
+
* — the index state corrupts every importer of its packages, so the failure
|
|
174
|
+
* cannot be attributed to the file's own edges), through the same
|
|
175
|
+
* `refuseUnreadTree` the manifest resolvers hold; this resolver and the Java
|
|
176
|
+
* one hold the identical check over the one shared index.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} workspace `{ projects, filesOf(name), readFile(path) }`
|
|
179
|
+
* @returns {{ source: string, target: string, sourceFile: string, type: string }[]}
|
|
180
|
+
* @throws {Error} when `jvmPackageIndex` recorded any failure, naming each
|
|
181
|
+
* unreadable JVM source.
|
|
182
|
+
*/
|
|
183
|
+
export function resolveKotlinDependencies(workspace) {
|
|
184
|
+
const { projects, filesOf, readFile } = workspace;
|
|
185
|
+
// The same refusal `./java.mjs`'s `resolveJavaDependencies` holds over the
|
|
186
|
+
// one shared index (#364's posture): an unreadable source corrupts every
|
|
187
|
+
// importer of its packages, so either resolver alone refuses the tree.
|
|
188
|
+
const { byName: index, failures: indexFailures } = jvmPackageIndex(workspace);
|
|
189
|
+
refuseUnreadTree("the JVM package index", indexFailures);
|
|
190
|
+
const dependencies = [];
|
|
191
|
+
for (const project of projects) {
|
|
192
|
+
for (const file of filesOf(project.name)) {
|
|
193
|
+
if (!file.endsWith(".kt") && !file.endsWith(".kts")) continue;
|
|
194
|
+
const text = readFile(file);
|
|
195
|
+
if (text === null) continue;
|
|
196
|
+
for (const site of parseKotlinImportSites(text)) {
|
|
197
|
+
const resolved = resolveJvmSpecifier(site.importableName, { language: "kotlin" }, index);
|
|
198
|
+
if (resolved.external || resolved.ambiguous) continue;
|
|
199
|
+
if (resolved.target === project.name) continue;
|
|
200
|
+
dependencies.push({
|
|
201
|
+
source: project.name,
|
|
202
|
+
target: resolved.target,
|
|
203
|
+
sourceFile: file,
|
|
204
|
+
type: "static",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return dependencies;
|
|
210
|
+
}
|
|
@@ -66,3 +66,33 @@ export function resolveWithinWorkspace(baseDir, relative) {
|
|
|
66
66
|
}
|
|
67
67
|
return segments.join("/");
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/** A pattern carrying any of these is a glob; anything else is a literal. */
|
|
71
|
+
const GLOB_METACHARACTERS = /[*?[{\\]/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Does a file's basename match any of a manifest-name pattern list — the
|
|
75
|
+
* literal patterns by equality, only the glob patterns through `matchesGlob`.
|
|
76
|
+
*
|
|
77
|
+
* Why the split: the lists this serves (the polyglot manifest names, a
|
|
78
|
+
* workspace's `projects.infer.manifests`) are mostly literals — `go.mod`,
|
|
79
|
+
* `Cargo.toml`, `pom.xml` — with a wildcard like `*.csproj` riding beside
|
|
80
|
+
* them, and the filters run once per tracked file. Measured, handing every
|
|
81
|
+
* pattern to `path.posix.matchesGlob` costs seconds past ~100k files where
|
|
82
|
+
* the equality scan it replaced was nanoseconds, because each call compiles
|
|
83
|
+
* its pattern again. The matcher is injected so `../../providers/native/
|
|
84
|
+
* model.mjs`'s validated one and raw `path.posix.matchesGlob` ride the same
|
|
85
|
+
* fast path without this module reaching for either; semantics are
|
|
86
|
+
* unchanged, because a metacharacter-free pattern answers identically
|
|
87
|
+
* either way and every other pattern still reaches the glob.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} base The basename under test.
|
|
90
|
+
* @param {readonly string[]} patterns
|
|
91
|
+
* @param {(value: string, pattern: string) => boolean} matchesGlob
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function basenameMatches(base, patterns, matchesGlob) {
|
|
95
|
+
return patterns.some((pattern) =>
|
|
96
|
+
GLOB_METACHARACTERS.test(pattern) ? matchesGlob(base, pattern) : pattern === base,
|
|
97
|
+
);
|
|
98
|
+
}
|
package/src/analysis/python.mjs
CHANGED
|
@@ -498,7 +498,8 @@ export function resolvePythonDependencies(projects, filesOf, readFile) {
|
|
|
498
498
|
*
|
|
499
499
|
* They are not filesystem paths, which is the other half: a dotted module name
|
|
500
500
|
* is resolved on `sys.path`, never by path arithmetic against the source file,
|
|
501
|
-
* so `spelling.path` is always false for Python
|
|
501
|
+
* so `spelling.path` is always false for Python and `spelling.namesOnly` always
|
|
502
|
+
* true. That distinction is what stops
|
|
502
503
|
* an unresolvable `from . import x` from being reported as
|
|
503
504
|
* `noRelativeOrAbsoluteExternals` — a message about a path, aimed at a name.
|
|
504
505
|
*
|
|
@@ -1177,7 +1178,7 @@ export function analyzePython({ sourceFile, text, workspace }) {
|
|
|
1177
1178
|
column,
|
|
1178
1179
|
specifier: site.specifier,
|
|
1179
1180
|
kind: site.kind,
|
|
1180
|
-
spelling: { path: false, relative: isRelativeImport(site.specifier) },
|
|
1181
|
+
spelling: { path: false, relative: isRelativeImport(site.specifier), namesOnly: true },
|
|
1181
1182
|
resolved: null,
|
|
1182
1183
|
};
|
|
1183
1184
|
result.imports.push(record);
|
|
@@ -52,6 +52,17 @@ export const LANGUAGE_BY_EXTENSION = Object.freeze({
|
|
|
52
52
|
".go": "go",
|
|
53
53
|
".rs": "rust",
|
|
54
54
|
".py": "python",
|
|
55
|
+
// The JVM pair registers together with its analyzer: the shared core under
|
|
56
|
+
// `src/analysis/jvm/` reads both extensions through ONE package index, and
|
|
57
|
+
// ADR 0005 (`docs/adr/`) owns why they are two languages rather than one.
|
|
58
|
+
".java": "java",
|
|
59
|
+
".kt": "kotlin",
|
|
60
|
+
".kts": "kotlin",
|
|
61
|
+
// C# joins the dotted-name family through the dotnet core (`src/analysis/
|
|
62
|
+
// dotnet/`, ADR 0006): one namespace index over `.cs` sources, one lexical
|
|
63
|
+
// mask, one manifest reader for the csproj family — with F# and VB.NET as
|
|
64
|
+
// later frontends of the same core rather than new stacks.
|
|
65
|
+
".cs": "csharp",
|
|
55
66
|
});
|
|
56
67
|
|
|
57
68
|
/**
|