@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.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/cli.mjs +126 -4
  3. package/commands.mjs +6 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +6 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +514 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +303 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +9 -2
  27. package/src/commands/context.mjs +84 -14
  28. package/src/commands/provenance.mjs +7 -44
  29. package/src/commands/rules.mjs +775 -0
  30. package/src/governance/profile-registry.mjs +0 -1
  31. package/src/graph/create-dependencies.mjs +138 -15
  32. package/src/lsp/diagnose.mjs +1 -1
  33. package/src/lsp/server.mjs +97 -1
  34. package/src/lsp/workspace-index.mjs +106 -15
  35. package/src/options.mjs +30 -7
  36. package/src/process.mjs +10 -1
  37. package/src/providers/moon.mjs +287 -36
  38. package/src/providers/native/differential.fixtures.mjs +32 -6
  39. package/src/providers/native/discover.mjs +83 -4
  40. package/src/providers/native/graph.mjs +58 -0
  41. package/src/providers/native/model.mjs +59 -1
  42. package/src/rules/index.mjs +21 -6
  43. package/src/rules/reachability.mjs +2 -0
  44. package/src/rules/tags.mjs +7 -5
  45. package/src/rules/topology.mjs +5 -3
  46. 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
+ }