@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,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
+ }
@@ -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. That distinction is what stops
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
  /**