@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,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Java analyzer — header-region extraction over comment-and-literal-masked
|
|
3
|
+
* text, in the shape `./go.mjs` established: one shared parse feeding both
|
|
4
|
+
* layers, so the graph edge and the import-site record can never disagree
|
|
5
|
+
* about what a file imports.
|
|
6
|
+
*
|
|
7
|
+
* Static analysis only, no JDK required (`../../../../docs/reference/languages.md`
|
|
8
|
+
* owns
|
|
9
|
+
* why graphs compute on machines with no toolchain). JLS §7.3 confines the
|
|
10
|
+
* compilation unit to `[PackageDecl] {ImportDecl} {TypeDecls}`, and §7.5.1–4
|
|
11
|
+
* fixes exactly four import forms:
|
|
12
|
+
*
|
|
13
|
+
* import a.b.C; single type
|
|
14
|
+
* import a.b.*; on-demand (wildcard)
|
|
15
|
+
* import static a.b.C.member; static single member
|
|
16
|
+
* import static a.b.C.*; static on-demand
|
|
17
|
+
*
|
|
18
|
+
* No dynamic import, no type-only form, no re-export syntax, no aliasing:
|
|
19
|
+
* `kind` is always `"static"` here, and `spelling.path` is always `false`
|
|
20
|
+
* because no Java import is spelled as a filesystem path — `spelling.namesOnly`
|
|
21
|
+
* is always `true`, a package name being a name and never a path (#376).
|
|
22
|
+
* `spelling.relative`
|
|
23
|
+
* takes Go's argued answer — true exactly when the import resolved into its
|
|
24
|
+
* own project — because Java offers no relative spelling either, so the bit
|
|
25
|
+
* reads what an import REACHED rather than how it was written.
|
|
26
|
+
*
|
|
27
|
+
* The specifier is the trimmed text between the keyword and the semicolon —
|
|
28
|
+
* `com.acme.Foo`, `com.acme.*`, `static com.acme.Util.FOO` — the same choice
|
|
29
|
+
* Python makes of keeping what is imported rather than the statement around
|
|
30
|
+
* it, minus the keyword itself.
|
|
31
|
+
*
|
|
32
|
+
* Known parse limits, deliberate and pinned by tests, each erring toward a
|
|
33
|
+
* record naming text the file really contains or toward a documented silence,
|
|
34
|
+
* never toward a wrong project:
|
|
35
|
+
*
|
|
36
|
+
* - A **multi-line import statement** (`import a.b.\n C;`) is not read:
|
|
37
|
+
* the name must sit on the declaration's own line. Every formatter formats
|
|
38
|
+
* imports onto one line, so this is the one limit a formatted tree never
|
|
39
|
+
* meets; the miss is silent for that import and compensated by the manifest
|
|
40
|
+
* resolvers' independent edges.
|
|
41
|
+
* - **Fully-qualified names used WITHOUT an import** are invisible by design
|
|
42
|
+
* — same-package references and inline FQNs need no import statement, so
|
|
43
|
+
* import-only extraction cannot see them. Documented for Kotlin too, where
|
|
44
|
+
* the identical gap exists.
|
|
45
|
+
* - A **raw identifier segment with Unicode letters** resolves like any
|
|
46
|
+
* other; backtick-quoted segments are not Java and are not read.
|
|
47
|
+
*/
|
|
48
|
+
import { maskJavaComments } from "./jvm/mask.mjs";
|
|
49
|
+
import { jvmPackageIndex } from "./jvm/packages.mjs";
|
|
50
|
+
import { resolveJvmSpecifier } from "./jvm/resolve.mjs";
|
|
51
|
+
import {
|
|
52
|
+
emptyResult,
|
|
53
|
+
fileFailure,
|
|
54
|
+
positionAt,
|
|
55
|
+
projectOwning,
|
|
56
|
+
refuseUnreadTree,
|
|
57
|
+
} from "./source-util.mjs";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every import in a `.java` file, in source order and WITHOUT deduplication —
|
|
61
|
+
* one entry per written import, which is what an import-site record is.
|
|
62
|
+
*
|
|
63
|
+
* Offsets index the ORIGINAL text: the mask preserves length, so the two are
|
|
64
|
+
* the same coordinate system. The name anchor is found by locating the
|
|
65
|
+
* captured name inside the matched span rather than by assuming where the
|
|
66
|
+
* regex left it.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} javaText Raw file contents.
|
|
69
|
+
* @returns {{ specifier: string, importableName: string, offset: number }[]}
|
|
70
|
+
*/
|
|
71
|
+
export function parseJavaImportSites(javaText) {
|
|
72
|
+
const source = maskJavaComments(javaText);
|
|
73
|
+
// Anchored to a line head — through a leading UTF-8 BOM, matched rather than
|
|
74
|
+
// stripped so offsets keep indexing the bytes on disk, the same anchor
|
|
75
|
+
// `./jvm/packages.mjs`'s package declaration and `./csharp.mjs` hold
|
|
76
|
+
// (#221's lesson) — OR behind a semicolon (`package p; import q.R;` is legal
|
|
77
|
+
// Java), with the name required to start on the same line, which pins the
|
|
78
|
+
// multi-line limit above instead of silently mis-reading one. Name grammar:
|
|
79
|
+
// dot-joined identifier segments with ONE optional trailing `.*` (the
|
|
80
|
+
// on-demand form); nothing else matches, so `a.*.b` and friends are text,
|
|
81
|
+
// not imports. The terminator is a lookahead, never a consumed `;` (#407):
|
|
82
|
+
// consuming it left the scan past the anchor of a second import on the same
|
|
83
|
+
// line, so `import a.B; import c.D;` read only the first — un-consumed, the
|
|
84
|
+
// `;` anchors the next import exactly as a line head does.
|
|
85
|
+
const SEG = String.raw`[\p{L}_$][\p{L}\p{Nd}_$]*`;
|
|
86
|
+
const JAVA_IMPORT = new RegExp(
|
|
87
|
+
`(?:^\\uFEFF?|[\\n;])[ \\t]*(?:import[ \\t]+)(static[ \\t]+)?(${SEG}(?:\\.${SEG})*(?:\\.\\*)?)[ \\t]*(?=;)`,
|
|
88
|
+
"gu",
|
|
89
|
+
);
|
|
90
|
+
const sites = [];
|
|
91
|
+
for (const match of source.matchAll(JAVA_IMPORT)) {
|
|
92
|
+
const staticKeyword = match[1] ?? "";
|
|
93
|
+
const name = match[2];
|
|
94
|
+
const specifier = `${staticKeyword}${name}`;
|
|
95
|
+
const nameOffsetInMatch = match[0].indexOf(name);
|
|
96
|
+
sites.push({
|
|
97
|
+
specifier,
|
|
98
|
+
importableName: importableNameOf(staticKeyword, name),
|
|
99
|
+
offset: match.index + nameOffsetInMatch,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return sites.sort((a, b) => a.offset - b.offset);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Why a `.java` file's imports cannot be fully read, as a reason for
|
|
107
|
+
* `analyzeJava` to record as a whole-file failure (`contract.md`): an
|
|
108
|
+
* `import` that never reaches its `;`.
|
|
109
|
+
*
|
|
110
|
+
* The detection mirrors the import regex's own failure conditions, so a file
|
|
111
|
+
* it reads fully is never flagged. The opener is the import regex's own head
|
|
112
|
+
* — line head through a BOM, or behind a `;`, then `import` and whitespace —
|
|
113
|
+
* which is also what keeps the header's documented single-line limit a LIMIT:
|
|
114
|
+
* a line-wrapped import (`import` then `\n`) matches neither this scan nor
|
|
115
|
+
* the site regex, so it stays a missed record, never a refusal. And the `;`
|
|
116
|
+
* is required to arrive before the next `{`: the brace a type body opens with
|
|
117
|
+
* is what separates a line-wrapped import (whose `;` precedes any body) from
|
|
118
|
+
* a truncated one (whose body opens first), so an `import` cut off before its
|
|
119
|
+
* `;` — a failed write, a merge marker left mid-import — no longer parses as
|
|
120
|
+
* zero import sites with no failure, byte-for-byte identical to a file that
|
|
121
|
+
* imports nothing (#419). The Go posture `goImportMalformations` set: shapes
|
|
122
|
+
* the regex answers are the documented parse limits; the shape it cannot
|
|
123
|
+
* answer is the failure.
|
|
124
|
+
*
|
|
125
|
+
* Comments, strings and text blocks are masked first — the same
|
|
126
|
+
* `maskJavaComments` the site parser runs — so import-shaped text a code
|
|
127
|
+
* generator's template or a comment holds is never read as import syntax and
|
|
128
|
+
* a compiling file is never reported as broken.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} javaText Raw file contents.
|
|
131
|
+
* @returns {string[]} One reason naming its line, or empty when the imports
|
|
132
|
+
* read fully.
|
|
133
|
+
*/
|
|
134
|
+
export function javaImportMalformations(javaText) {
|
|
135
|
+
const source = maskJavaComments(javaText);
|
|
136
|
+
const JAVA_IMPORT_HEAD = /(?:^\uFEFF?|[\n;])[ \t]*(?:import[ \t]+)/gu;
|
|
137
|
+
/** @type {string[]} */
|
|
138
|
+
const reasons = [];
|
|
139
|
+
// `;` and `{` ascend with the text, and so do the openers, so one shared
|
|
140
|
+
// cursor walks both lists in a single pass — the same shape the Rust scan's
|
|
141
|
+
// non-overlapping windows hold, instead of an `indexOf` per opener that
|
|
142
|
+
// rescans the tail (`.rs` content is attacker-supplied per SECURITY.md).
|
|
143
|
+
const terminators = [...source.matchAll(/[;{]/g)];
|
|
144
|
+
let cursor = 0;
|
|
145
|
+
for (const m of source.matchAll(JAVA_IMPORT_HEAD)) {
|
|
146
|
+
const at = m.index + m[0].length;
|
|
147
|
+
while (cursor < terminators.length && terminators[cursor].index < at) cursor += 1;
|
|
148
|
+
const next = terminators[cursor];
|
|
149
|
+
if (next === undefined || next[0] === "{") {
|
|
150
|
+
// The matched span starts at its ANCHOR (a `\n` or `;`), so the
|
|
151
|
+
// reason must locate the keyword inside the span rather than point at
|
|
152
|
+
// m.index — the anchor is the PREVIOUS line when it is a `\n`, and a
|
|
153
|
+
// diagnostic naming the wrong line sends every reader to the wrong
|
|
154
|
+
// import. The same locate-it move `parseJavaImportSites` makes for
|
|
155
|
+
// the name.
|
|
156
|
+
const importOffset = m.index + m[0].indexOf("import");
|
|
157
|
+
reasons.push(
|
|
158
|
+
"an `import` never reaches its `;` — the file is truncated or malformed, " +
|
|
159
|
+
`so its imports cannot be read (line ${positionAt(javaText, importOffset).line})`,
|
|
160
|
+
);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return reasons;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The dotted name resolution walks for an import, stripped of the parts that
|
|
169
|
+
* name members rather than packages:
|
|
170
|
+
*
|
|
171
|
+
* - the on-demand form drops its trailing `.*` (for `import a.b.*` the
|
|
172
|
+
* importable is the package `a.b`; for `import static a.b.C.*` it is the
|
|
173
|
+
* package-plus-type `a.b.C`, whose longest DECLARED prefix still resolves
|
|
174
|
+
* to `a`'s owner);
|
|
175
|
+
* - static single forms drop their last segment (the member — field, method,
|
|
176
|
+
* or static nested type), leaving package-plus-type whose longest declared
|
|
177
|
+
* prefix resolves the same way any import does;
|
|
178
|
+
* - everything else resolves whole: a nested-type import (`a.b.Outer.Inner`)
|
|
179
|
+
* stops at the deepest DECLARED package naturally, because types are not
|
|
180
|
+
* index keys.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} staticKeyword The captured `static ` keyword or "".
|
|
183
|
+
* @param {string} name The dotted name as written.
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
function importableNameOf(staticKeyword, name) {
|
|
187
|
+
if (name.endsWith(".*")) return name.slice(0, -2);
|
|
188
|
+
if (staticKeyword !== "") {
|
|
189
|
+
const lastDot = name.lastIndexOf(".");
|
|
190
|
+
return lastDot === -1 ? name : name.slice(0, lastDot);
|
|
191
|
+
}
|
|
192
|
+
return name;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Analyzes one `.java` file.
|
|
197
|
+
*
|
|
198
|
+
* An ambiguous package (two tracked projects declaring the same deepest
|
|
199
|
+
* prefix) resolves to `resolved: null` WITH a positioned failure naming both
|
|
200
|
+
* projects — the split-package case, where picking either side would report
|
|
201
|
+
* violations against a guess. Intra-project imports are emitted as records
|
|
202
|
+
* (`contract.md`), with `spelling.relative` true exactly there.
|
|
203
|
+
*
|
|
204
|
+
* The package index arrives through `jvmPackageIndex` — already memoized per
|
|
205
|
+
* workspace object — so one whole-tree run builds it once however many files
|
|
206
|
+
* ask, and the graph resolver below reads the same map through the same memo.
|
|
207
|
+
*
|
|
208
|
+
* @param {{ sourceFile: string, text: string, workspace: object }} request
|
|
209
|
+
* @returns {{ imports: object[], failures: object[] }}
|
|
210
|
+
*/
|
|
211
|
+
export function analyzeJava({ sourceFile, text, workspace }) {
|
|
212
|
+
const result = emptyResult();
|
|
213
|
+
try {
|
|
214
|
+
const { byName: index } = jvmPackageIndex(workspace);
|
|
215
|
+
const owner = projectOwning(workspace.projects, sourceFile);
|
|
216
|
+
// A file truncated inside an import used to parse as importing nothing,
|
|
217
|
+
// with no failure beside the empty result — the clean verdict over it was
|
|
218
|
+
// the bug (#419). The whole-file shape is what turns the verdict loud:
|
|
219
|
+
// `check` counts the file toward `unchecked` and refuses to call the run
|
|
220
|
+
// complete, instead of reporting a hole as a clean file.
|
|
221
|
+
for (const reason of javaImportMalformations(text)) {
|
|
222
|
+
result.failures.push(fileFailure(sourceFile, reason));
|
|
223
|
+
}
|
|
224
|
+
for (const site of parseJavaImportSites(text)) {
|
|
225
|
+
const { line, column } = positionAt(text, site.offset);
|
|
226
|
+
const resolved = resolveJvmSpecifier(site.importableName, { language: "java" }, index);
|
|
227
|
+
let resolution;
|
|
228
|
+
if (resolved.external) {
|
|
229
|
+
// A name no tracked project claims: classified, never dropped, and
|
|
230
|
+
// deliberately NOT added as an externalNodes entry here — only
|
|
231
|
+
// project↔project edges matter to the graph (`AGENTS.md`).
|
|
232
|
+
resolution = {
|
|
233
|
+
target: null,
|
|
234
|
+
file: null,
|
|
235
|
+
external: true,
|
|
236
|
+
packageName: site.importableName,
|
|
237
|
+
};
|
|
238
|
+
} else if (resolved.ambiguous) {
|
|
239
|
+
// Split package: unresolvable by static reading, so `resolved` is
|
|
240
|
+
// null WITH a positioned failure naming every claimant — the Python
|
|
241
|
+
// PEP 420 precedent, and never an edge against a guess.
|
|
242
|
+
resolution = null;
|
|
243
|
+
result.failures.push({
|
|
244
|
+
sourceFile,
|
|
245
|
+
line,
|
|
246
|
+
column,
|
|
247
|
+
reason:
|
|
248
|
+
`'${resolved.matchedPrefix}' is declared by more than one project ` +
|
|
249
|
+
`(${resolved.ambiguous.join(", ")}) — Java would pick by classpath order, ` +
|
|
250
|
+
`which this static reader does not model`,
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
resolution = { target: resolved.target, file: null, external: false, packageName: null };
|
|
254
|
+
}
|
|
255
|
+
const target = resolution?.target ?? null;
|
|
256
|
+
result.imports.push({
|
|
257
|
+
sourceFile,
|
|
258
|
+
line,
|
|
259
|
+
column,
|
|
260
|
+
specifier: site.specifier,
|
|
261
|
+
kind: "static",
|
|
262
|
+
spelling: {
|
|
263
|
+
path: false,
|
|
264
|
+
relative: target !== null && owner !== null && target === owner.name,
|
|
265
|
+
namesOnly: true,
|
|
266
|
+
},
|
|
267
|
+
resolved: resolution,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
result.failures.push(
|
|
272
|
+
fileFailure(sourceFile, `Java analysis failed: ${cause?.message ?? cause}`),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Static edges between JVM projects derived from written imports — the
|
|
280
|
+
* source-truth half of the two-track principle. `resolveMavenDependencies`
|
|
281
|
+
* owns the manifest half; neither replaces the other.
|
|
282
|
+
*
|
|
283
|
+
* Takes ONE workspace-shaped object (`{ projects, filesOf, readFile }`) rather
|
|
284
|
+
* than the positional triple: the package index is memoized on that object,
|
|
285
|
+
* so the caller's one object — shared with `analyzeJava`, the Kotlin resolver
|
|
286
|
+
* and the manifest resolvers — is what makes the index build once per run
|
|
287
|
+
* instead of once per call site (#363). Returns raw Nx dependencies
|
|
288
|
+
* ({ source, target, sourceFile, type: "static" }). Ambiguous names draw no
|
|
289
|
+
* edge — analysis reports them loudly instead, and an edge against a guess
|
|
290
|
+
* would be worse than the missing one. An unreadable `.java`/`.kt` source
|
|
291
|
+
* refuses the whole graph (#364's posture — the index state corrupts every
|
|
292
|
+
* importer of its packages, so the failure cannot be attributed to the
|
|
293
|
+
* file's own edges), through the same `refuseUnreadTree` the manifest
|
|
294
|
+
* resolvers hold.
|
|
295
|
+
*
|
|
296
|
+
* @param {object} workspace `{ projects, filesOf(name), readFile(path) }`
|
|
297
|
+
* @returns {{ source: string, target: string, sourceFile: string, type: string }[]}
|
|
298
|
+
* @throws {Error} when `jvmPackageIndex` recorded any failure, naming each
|
|
299
|
+
* unreadable JVM source.
|
|
300
|
+
*/
|
|
301
|
+
export function resolveJavaDependencies(workspace) {
|
|
302
|
+
const { projects, filesOf, readFile } = workspace;
|
|
303
|
+
// #364's posture closes the gap #397's comment below named: the hook DOES
|
|
304
|
+
// have a loud channel — a throw, which Nx turns into a failed graph
|
|
305
|
+
// computation — so the index's read failures are consumed here after all,
|
|
306
|
+
// through the same `refuseUnreadTree` the manifest readers hold.
|
|
307
|
+
const { byName: index, failures: indexFailures } = jvmPackageIndex(workspace);
|
|
308
|
+
refuseUnreadTree("the JVM package index", indexFailures);
|
|
309
|
+
const dependencies = [];
|
|
310
|
+
for (const project of projects) {
|
|
311
|
+
for (const file of filesOf(project.name)) {
|
|
312
|
+
if (!file.endsWith(".java")) continue;
|
|
313
|
+
const text = readFile(file);
|
|
314
|
+
if (text === null) continue;
|
|
315
|
+
for (const site of parseJavaImportSites(text)) {
|
|
316
|
+
const resolved = resolveJvmSpecifier(site.importableName, { language: "java" }, index);
|
|
317
|
+
if (resolved.external || resolved.ambiguous) continue;
|
|
318
|
+
if (resolved.target === project.name) continue;
|
|
319
|
+
dependencies.push({
|
|
320
|
+
source: project.name,
|
|
321
|
+
target: resolved.target,
|
|
322
|
+
sourceFile: file,
|
|
323
|
+
type: "static",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return dependencies;
|
|
329
|
+
}
|