@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
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **Architecture governance for polyglot repositories** — a deterministic
4
4
  authority that keeps the architecture your team declared aligned with the code
5
5
  your team keeps changing. Dependency graphs and module boundaries for Go, Rust,
6
- Python, TypeScript, JavaScript and Vue, with Nx and Moon as first-class
6
+ Python, TypeScript, JavaScript, Vue, Java, Kotlin and C#, with Nx and Moon as first-class
7
7
  integrations. Coding agents read the same verdicts, machine-readably, through
8
8
  the `arch-*` skills. The system boundary — what Archkeep is and what it is not —
9
9
  is owned by [architecture-authority.md](https://github.com/ecoma-io/archkeep/blob/main/docs/doctrine/architecture-authority.md).
@@ -182,7 +182,7 @@ a file it cannot edit ([overview.md](https://github.com/ecoma-io/archkeep/blob/m
182
182
 
183
183
  ## Support
184
184
 
185
- - **Languages** — Go, Rust, Python, TypeScript and JavaScript, and Vue. Analysis
185
+ - **Languages** — Go, Rust, Python, TypeScript and JavaScript, Vue, Java, Kotlin and C#. Analysis
186
186
  from source, never a build: nothing shells out to `go`, `cargo`, `uv` or `tsc`
187
187
  ([languages.md](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/languages.md)).
188
188
  - **Workspaces** — any repository: Nx registers the plugin in `nx.json` and
@@ -225,7 +225,7 @@ Ten minutes end to end, most of it spent deciding what your tags mean:
225
225
  Ten minutes end to end, most of it spent deciding what your tags mean:
226
226
  [**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
227
227
  `history`, `trajectory`, `evolution`, `drift`, `impact`, `explain`,
228
- `context` and the rest of the 21-command surface are in the
228
+ `context` and the rest of the 22-command surface are in the
229
229
  [CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
230
230
 
231
231
  ## Documentation map
package/cli.mjs CHANGED
@@ -124,6 +124,12 @@ import { debtCommand } from "./src/commands/debt.mjs";
124
124
  import { explainCommand } from "./src/commands/explain.mjs";
125
125
  import { impactCommand } from "./src/commands/impact.mjs";
126
126
  import { provenanceCommand } from "./src/commands/provenance-command.mjs";
127
+ import {
128
+ rulesAddCommand,
129
+ rulesInfoCommand,
130
+ rulesListCommand,
131
+ rulesVerifyCommand,
132
+ } from "./src/commands/rules.mjs";
127
133
  import { waiversCommand } from "./src/commands/waivers.mjs";
128
134
  import { INTENT_FILE, loadIntent } from "./src/architecture-intent/model.mjs";
129
135
  import { isProgramEntry } from "./src/entry-point.mjs";
@@ -1674,8 +1680,8 @@ async function runAdr(options, { cwd, env }) {
1674
1680
  const root = resolveWorkspaceRootForUsage(cwd);
1675
1681
  if (root === null) {
1676
1682
  env.err(
1677
- `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or .moon marker found ` +
1678
- `walking up from ${cwd}`,
1683
+ `archkeep: adr needs a workspace root — no nx.json, archkeep.json, or ` +
1684
+ `.moon/workspace.yml marker found walking up from ${cwd}`,
1679
1685
  );
1680
1686
  return EXIT.error;
1681
1687
  }
@@ -1707,6 +1713,72 @@ async function runAdr(options, { cwd, env }) {
1707
1713
  return result.status === "ok" ? EXIT.ok : EXIT.error;
1708
1714
  }
1709
1715
 
1716
+ /**
1717
+ * `rules`'s `run`: dispatches to the appropriate subcommand (list/info/verify/add),
1718
+ * drives it, writes the report, and returns the exit code.
1719
+ *
1720
+ * @param {{format: string, output: string|null, catalog: string|null, to: string|null, paths: string[]}} options
1721
+ * @param {{cwd: string, env: {out: Function, err: Function}}} runContext
1722
+ * @returns {Promise<number>}
1723
+ */
1724
+ async function runRules(options, { cwd, env }) {
1725
+ if (options.paths.length === 0) {
1726
+ env.err(`archkeep: rules requires a subcommand (list, info, verify, or add)`);
1727
+ return EXIT.usage;
1728
+ }
1729
+
1730
+ const subcommand = options.paths[0];
1731
+ const ruleName = options.paths[1];
1732
+
1733
+ const dispatch = {
1734
+ list: rulesListCommand,
1735
+ info: rulesInfoCommand,
1736
+ verify: rulesVerifyCommand,
1737
+ add: rulesAddCommand,
1738
+ }[subcommand];
1739
+
1740
+ if (!dispatch) {
1741
+ env.err(
1742
+ `archkeep: unknown rules subcommand '${subcommand}' — must be list, info, verify, or add`,
1743
+ );
1744
+ return EXIT.usage;
1745
+ }
1746
+
1747
+ if (subcommand === "info" && !ruleName) {
1748
+ env.err(`archkeep: rules info requires a rule name`);
1749
+ return EXIT.usage;
1750
+ }
1751
+
1752
+ if (subcommand === "add" && !ruleName) {
1753
+ env.err(`archkeep: rules add requires a rule name`);
1754
+ return EXIT.usage;
1755
+ }
1756
+
1757
+ let result;
1758
+ try {
1759
+ const commandOptions = { catalog: options.catalog, to: options.to };
1760
+ result = await dispatch(commandOptions, { cwd, ruleName });
1761
+ } catch (error) {
1762
+ env.err(String(error?.message ?? error));
1763
+ return EXIT.error;
1764
+ }
1765
+
1766
+ const report = options.format === "json" ? result.report.json : result.report.text;
1767
+
1768
+ if (options.output) {
1769
+ const reportText = report.endsWith("\n") ? report : `${report}\n`;
1770
+ if (!writeOutputReport(options.output, reportText, env, cwd, null)) return EXIT.error;
1771
+ env.err(`archkeep: rules ${subcommand} complete → ${options.output}`);
1772
+ } else {
1773
+ env.out(report);
1774
+ }
1775
+
1776
+ // Exit codes: 0 for ok, 1 for findings (verify only), 3 for no-verdict
1777
+ if (result.status === "ok") return EXIT.ok;
1778
+ if (result.status === "findings") return EXIT.violations;
1779
+ return EXIT.error;
1780
+ }
1781
+
1710
1782
  /**
1711
1783
  * `history`'s `run`: resolves the command context, optionally captures a
1712
1784
  * snapshot of the current workspace, drives `historyCommand`, writes the
@@ -1909,8 +1981,8 @@ async function runEvolution(options, { cwd, env }) {
1909
1981
  const root = findWorkspaceRoot(cwd, WORKSPACE_MARKERS);
1910
1982
  if (root === null) {
1911
1983
  env.err(
1912
- `archkeep: evolution needs a workspace root — no nx.json, archkeep.json, or .moon marker found ` +
1913
- `walking up from ${cwd}`,
1984
+ `archkeep: evolution needs a workspace root — no nx.json, archkeep.json, or ` +
1985
+ `.moon/workspace.yml marker found walking up from ${cwd}`,
1914
1986
  );
1915
1987
  return EXIT.error;
1916
1988
  }
@@ -2991,6 +3063,46 @@ const ADR_FLAG_HELP = Object.freeze([
2991
3063
  }),
2992
3064
  ]);
2993
3065
 
3066
+ /**
3067
+ * `rules`'s flags: catalog path, format, output, and target directory for add.
3068
+ * The first positional is the subcommand (list/info/verify/add), the second is
3069
+ * the rule name for info and add.
3070
+ *
3071
+ * @type {readonly FlagHelp[]}
3072
+ */
3073
+ const RULES_FLAG_HELP = Object.freeze([
3074
+ Object.freeze({
3075
+ flag: "--catalog",
3076
+ key: "catalog",
3077
+ arg: "<path>",
3078
+ describe: Object.freeze([
3079
+ "Path to the catalog file",
3080
+ "(default: node_modules/@ecoma-io/archkeep-rules/catalog.json)",
3081
+ ]),
3082
+ }),
3083
+ Object.freeze({
3084
+ flag: "--format",
3085
+ key: "format",
3086
+ arg: "text|json",
3087
+ describe: Object.freeze([
3088
+ "Terminal report (default) or the versioned JSON envelope",
3089
+ "docs/reference/json-output.md documents",
3090
+ ]),
3091
+ }),
3092
+ Object.freeze({
3093
+ flag: "--output",
3094
+ key: "output",
3095
+ arg: "<file>",
3096
+ describe: Object.freeze(["Write the report to a file instead of stdout"]),
3097
+ }),
3098
+ Object.freeze({
3099
+ flag: "--to",
3100
+ key: "to",
3101
+ arg: "<dir>",
3102
+ describe: Object.freeze(["Target directory for the .wasm file", "(default: tools/rules/)"]),
3103
+ }),
3104
+ ]);
3105
+
2994
3106
  /**
2995
3107
  * The command table `usage()` and `runCli` both read from — a command added
2996
3108
  * later is a new entry here, not a new branch in either. `args` is the
@@ -3215,6 +3327,16 @@ const COMMANDS = Object.freeze({
3215
3327
  formats: DESCRIBABLE_FORMATS,
3216
3328
  run: runAdr,
3217
3329
  }),
3330
+ rules: Object.freeze({
3331
+ name: "rules",
3332
+ args: "<list|info|verify|add> [<rule-name>]",
3333
+ summary: "List official rules, show details, verify catalog integrity, or add a rule",
3334
+ flagHelp: RULES_FLAG_HELP,
3335
+ flags: Object.freeze(Object.fromEntries(RULES_FLAG_HELP.map((f) => [f.flag, f.key]))),
3336
+ defaults: Object.freeze({ format: "text", output: null, catalog: null, to: null }),
3337
+ formats: DESCRIBABLE_FORMATS,
3338
+ run: runRules,
3339
+ }),
3218
3340
  });
3219
3341
 
3220
3342
  /**
package/commands.mjs CHANGED
@@ -47,5 +47,11 @@ export { historyCommand } from "./src/commands/history.mjs";
47
47
  export { adrCommand } from "./src/commands/adr.mjs";
48
48
  export { discoverCommand } from "./src/commands/discover.mjs";
49
49
  export { reconcileCommand } from "./src/commands/reconcile.mjs";
50
+ export {
51
+ rulesAddCommand,
52
+ rulesInfoCommand,
53
+ rulesListCommand,
54
+ rulesVerifyCommand,
55
+ } from "./src/commands/rules.mjs";
50
56
 
51
57
  export { UsageError } from "./src/errors.mjs";
package/lsp.mjs CHANGED
@@ -68,11 +68,24 @@ export function serve(input = process.stdin, output = process.stdout, onExit = n
68
68
  return;
69
69
  }
70
70
  pending = framed.rest;
71
- for (const message of framed.messages) server.handle(message);
71
+ for (const message of framed.messages) {
72
+ // `handle` is async and owns its own error envelope — a rejection here
73
+ // is a wiring defect, not a diagnostic. The catch reports it on stderr
74
+ // (the channel a client surfaces as its server log) instead of letting
75
+ // it become an unhandled rejection that kills the connection, or
76
+ // nothing at all, which would read as a clean frame.
77
+ server
78
+ .handle(message)
79
+ .catch((error) => process.stderr.write(`archkeep-lsp: ${error?.message ?? error}\n`));
80
+ }
72
81
  });
73
82
  // A client that closed the pipe without saying `exit` did not shut the server
74
83
  // down; reporting that as a clean stop would hide a crashed editor.
75
- input.on("end", () => server.handle({ jsonrpc: "2.0", method: "exit" }));
84
+ input.on("end", () => {
85
+ server
86
+ .handle({ jsonrpc: "2.0", method: "exit" })
87
+ .catch((error) => process.stderr.write(`archkeep-lsp: ${error?.message ?? error}\n`));
88
+ });
76
89
  return server;
77
90
  }
78
91
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.15.0",
4
- "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript and Vue",
3
+ "version": "0.16.1",
4
+ "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
5
5
  "keywords": [
6
6
  "architecture",
7
7
  "enforcement",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@nx/eslint-plugin": ">=21",
56
+ "fast-xml-parser": "5.11.0",
56
57
  "nx": ">=21",
57
58
  "typescript": ">=5 <7",
58
59
  "vue": ">=3"
@@ -61,6 +62,9 @@
61
62
  "@nx/eslint-plugin": {
62
63
  "optional": true
63
64
  },
65
+ "fast-xml-parser": {
66
+ "optional": true
67
+ },
64
68
  "nx": {
65
69
  "optional": true
66
70
  },
@@ -22,7 +22,10 @@
22
22
  * exists. Every other import site keeps naming them from this module.
23
23
  */
24
24
 
25
+ import { analyzeCSharp } from "./csharp.mjs";
25
26
  import { analyzeGo } from "./go.mjs";
27
+ import { analyzeJava } from "./java.mjs";
28
+ import { analyzeKotlin } from "./kotlin.mjs";
26
29
  import { analyzePython } from "./python.mjs";
27
30
  import { LANGUAGE_BY_EXTENSION, languageOf } from "./registry.mjs";
28
31
  import { analyzeRust } from "./rust.mjs";
@@ -78,6 +81,15 @@ export { LANGUAGE_BY_EXTENSION, languageOf };
78
81
  * `./x` and `../x` in JavaScript, `crate::`/`self::`/`super::` and a sibling
79
82
  * crate target of the same Cargo package in Rust, a leading-dot import in
80
83
  * Python. It is the counter-evidence `noSelfCircularDependencies` looks for.
84
+ * @property {boolean} namesOnly The language's ONLY import spelling is a
85
+ * name — a Go module path, a Rust crate, a Python dotted module, a C#
86
+ * namespace, a Java or Kotlin package — so no specifier it produces is ever
87
+ * a filesystem path, and text beginning `libs/…` or `apps/…` is a name whose
88
+ * first segments are those words, not a path into a project. Constant on
89
+ * every record one analyzer produces, unlike `path` and `relative`, which
90
+ * answer per specifier. `isAbsoluteImportIntoAnotherProject` is gated on it
91
+ * (#376): false in the JavaScript family, where a bare `libs/x` is a deep
92
+ * import and a `/libs/x` an absolute path.
81
93
  */
82
94
 
83
95
  /**
@@ -170,6 +182,9 @@ const ANALYZER_BY_LANGUAGE = Object.freeze({
170
182
  go: analyzeGo,
171
183
  rust: analyzeRust,
172
184
  python: analyzePython,
185
+ java: analyzeJava,
186
+ kotlin: analyzeKotlin,
187
+ csharp: analyzeCSharp,
173
188
  });
174
189
 
175
190
  /**
@@ -71,23 +71,40 @@ beats every consumer remembering which convention this tool chose.
71
71
  ### How the specifier is spelled is a per-language fact, so the analyzer states it
72
72
 
73
73
  `specifier` is the raw text; `spelling` is what that text IS in the language it
74
- was written in. Two bits, because the rules ask two independent questions:
75
-
76
- | | `path` | `relative` |
77
- | --------------------------------- | :----: | :--------: |
78
- | `./x`, `../x`, `.`, `..` (JS) | yes | yes |
79
- | `/x` (JS) | yes | no |
80
- | `crate::x`, `self::x`, `super::x` | no | yes |
81
- | `rba_desktop_lib::run` from a bin | no | yes |
82
- | `.mod`, `..pkg.sub` (Python) | no | yes |
83
- | `react`, `serde`, `example.com/m` | no | no |
74
+ was written in. Three bits, because the rules ask three independent questions
75
+ the first two per specifier, the third per language and therefore constant on
76
+ every record one analyzer produces:
77
+
78
+ | | `path` | `relative` | `namesOnly` |
79
+ | --------------------------------- | :----: | :--------: | :---------: |
80
+ | `./x`, `../x`, `.`, `..` (JS) | yes | yes | no |
81
+ | `/x` (JS) | yes | no | no |
82
+ | `react` (JS, bare) | no | no | no |
83
+ | `crate::x`, `self::x`, `super::x` | no | yes | yes |
84
+ | `rba_desktop_lib::run` from a bin | no | yes | yes |
85
+ | `.mod`, `..pkg.sub` (Python) | no | yes | yes |
86
+ | `react`, `serde`, `example.com/m` | no | no | yes* |
87
+
88
+ \* the last row holds for `serde` and `example.com/m`; a JavaScript `react` is
89
+ `namesOnly: no` — the same text is a package name in one language and a name
90
+ that IS the only spelling in another, which is exactly why the bit rides on the
91
+ record instead of being derived from the text.
84
92
 
85
93
  `path` says the specifier is a **filesystem path**: resolvable by path
86
94
  arithmetic against the importing file, and naming no package. It decides
87
95
  whether a specifier may receive a synthesized external node, and which message
88
96
  an unresolvable one gets. `relative` says the specifier **reaches inside its
89
97
  own project without going out through the project's public name** — the
90
- counter-evidence `noSelfCircularDependencies` looks for.
98
+ counter-evidence `noSelfCircularDependencies` looks for. `namesOnly` says the
99
+ **language has no path spelling at all** — every specifier it produces is a
100
+ name, so no path-text rule applies to any of them. It gates
101
+ `isAbsoluteImportIntoAnotherProject`, which judges the JavaScript-family
102
+ spellings a bare `libs/x` deep import and a `/libs/x` absolute path: applied to
103
+ a language whose only spelling is the name, it read a Go `module libs/foo`'s
104
+ legal `import "libs/foo/bar"` as an absolute path into a project — an
105
+ unfixable verdict, because the name is the only spelling the language has
106
+ (#376). The edge such an import resolves to is still judged by every rule
107
+ below the spelling check; only the spelling check itself stands down.
91
108
 
92
109
  **Why the analyzer answers and not the rules.** The rules layer used to derive
93
110
  both from the text with one predicate — `.`, `..`, `./`, `../` — which is
@@ -101,10 +118,10 @@ import; the rules layer knows neither, and a language table there would be a
101
118
  second registry drifting from `LANGUAGE_BY_EXTENSION`. So the record carries the
102
119
  fact and the rule reads it.
103
120
 
104
- The field is **mandatory**, and `evaluate()` throws on a record that omits it
105
- rather than falling back to the JavaScript shape. A default is how the next
106
- analyzer inherits this bug silently; a throw is how it is told, once, at the
107
- first record it produces.
121
+ The field is **mandatory**, and `evaluate()` throws on a record that omits any
122
+ bit of it rather than falling back to the JavaScript shape. A default is how
123
+ the next analyzer inherits this bug silently; a throw is how it is told, once,
124
+ at the first record it produces.
108
125
 
109
126
  ### Intra-project imports are emitted too
110
127
 
@@ -208,9 +225,10 @@ rule cares about, and the pressure to fix it would be an ignore list that
208
225
  someone has to keep in sync with reality. An extension with no analyzer simply
209
226
  has no imports this tool can see, which is the truth.
210
227
 
211
- The registry is `LANGUAGE_BY_EXTENSION` in `analyze.mjs`, and it is the one
212
- place an extension is mapped. A language whose analyzer arrives adds itself
213
- there and nowhere else.
228
+ The registry is `LANGUAGE_BY_EXTENSION` in `registry.mjs` (re-exported by
229
+ `analyze.mjs`, so nothing that imports it from there has to change), and it is
230
+ the one place an extension is mapped. A language whose analyzer arrives adds
231
+ itself there and nowhere else.
214
232
 
215
233
  ## What an analyzer is handed
216
234