@hydranium/cli 1.0.0-next.10

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 (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/heap-analysis/analyze-heap.mjs +798 -0
  4. package/heap-analysis/heap/core/aggregate.mjs +152 -0
  5. package/heap-analysis/heap/core/bucket-label.mjs +23 -0
  6. package/heap-analysis/heap/core/cutpoints.mjs +51 -0
  7. package/heap-analysis/heap/core/diff.mjs +56 -0
  8. package/heap-analysis/heap/core/format.mjs +47 -0
  9. package/heap-analysis/heap/core/holders.mjs +80 -0
  10. package/heap-analysis/heap/core/load.mjs +46 -0
  11. package/heap-analysis/heap/core/report.mjs +68 -0
  12. package/heap-analysis/heap/core/strings.mjs +80 -0
  13. package/heap-analysis/heap/langium/concepts.mjs +230 -0
  14. package/heap-analysis/heap/langium/glsp.classifier.mjs +80 -0
  15. package/heap-analysis/heap/langium/langium.classifier.mjs +203 -0
  16. package/heap-analysis/heap/renderer/renderer.mjs +122 -0
  17. package/lib/cli-version.d.ts +21 -0
  18. package/lib/cli-version.d.ts.map +1 -0
  19. package/lib/cli-version.js +30 -0
  20. package/lib/cli-version.js.map +1 -0
  21. package/lib/cli.d.ts +11 -0
  22. package/lib/cli.d.ts.map +1 -0
  23. package/lib/cli.js +135 -0
  24. package/lib/cli.js.map +1 -0
  25. package/lib/commands/analyze-heap-args.d.ts +46 -0
  26. package/lib/commands/analyze-heap-args.d.ts.map +1 -0
  27. package/lib/commands/analyze-heap-args.js +174 -0
  28. package/lib/commands/analyze-heap-args.js.map +1 -0
  29. package/lib/commands/analyze-heap.d.ts +37 -0
  30. package/lib/commands/analyze-heap.d.ts.map +1 -0
  31. package/lib/commands/analyze-heap.js +63 -0
  32. package/lib/commands/analyze-heap.js.map +1 -0
  33. package/lib/commands/ast-ground-truth-args.d.ts +23 -0
  34. package/lib/commands/ast-ground-truth-args.d.ts.map +1 -0
  35. package/lib/commands/ast-ground-truth-args.js +47 -0
  36. package/lib/commands/ast-ground-truth-args.js.map +1 -0
  37. package/lib/commands/ast-ground-truth-driver.d.ts +10 -0
  38. package/lib/commands/ast-ground-truth-driver.d.ts.map +1 -0
  39. package/lib/commands/ast-ground-truth-driver.js +72 -0
  40. package/lib/commands/ast-ground-truth-driver.js.map +1 -0
  41. package/lib/commands/ast-ground-truth.d.ts +31 -0
  42. package/lib/commands/ast-ground-truth.d.ts.map +1 -0
  43. package/lib/commands/ast-ground-truth.js +36 -0
  44. package/lib/commands/ast-ground-truth.js.map +1 -0
  45. package/lib/commands/generate-transfer-model-args.d.ts +38 -0
  46. package/lib/commands/generate-transfer-model-args.d.ts.map +1 -0
  47. package/lib/commands/generate-transfer-model-args.js +183 -0
  48. package/lib/commands/generate-transfer-model-args.js.map +1 -0
  49. package/lib/commands/generate-transfer-model-config.d.ts +32 -0
  50. package/lib/commands/generate-transfer-model-config.d.ts.map +1 -0
  51. package/lib/commands/generate-transfer-model-config.js +122 -0
  52. package/lib/commands/generate-transfer-model-config.js.map +1 -0
  53. package/lib/commands/generate-transfer-model.d.ts +110 -0
  54. package/lib/commands/generate-transfer-model.d.ts.map +1 -0
  55. package/lib/commands/generate-transfer-model.js +587 -0
  56. package/lib/commands/generate-transfer-model.js.map +1 -0
  57. package/lib/commands/harness-args.d.ts +198 -0
  58. package/lib/commands/harness-args.d.ts.map +1 -0
  59. package/lib/commands/harness-args.js +339 -0
  60. package/lib/commands/harness-args.js.map +1 -0
  61. package/lib/commands/headless-harness.d.ts +97 -0
  62. package/lib/commands/headless-harness.d.ts.map +1 -0
  63. package/lib/commands/headless-harness.js +121 -0
  64. package/lib/commands/headless-harness.js.map +1 -0
  65. package/lib/commands/init-args.d.ts +81 -0
  66. package/lib/commands/init-args.d.ts.map +1 -0
  67. package/lib/commands/init-args.js +250 -0
  68. package/lib/commands/init-args.js.map +1 -0
  69. package/lib/commands/init-prompt.d.ts +66 -0
  70. package/lib/commands/init-prompt.d.ts.map +1 -0
  71. package/lib/commands/init-prompt.js +71 -0
  72. package/lib/commands/init-prompt.js.map +1 -0
  73. package/lib/commands/init-templates.d.ts +42 -0
  74. package/lib/commands/init-templates.d.ts.map +1 -0
  75. package/lib/commands/init-templates.js +1902 -0
  76. package/lib/commands/init-templates.js.map +1 -0
  77. package/lib/commands/init-wizard.d.ts +65 -0
  78. package/lib/commands/init-wizard.d.ts.map +1 -0
  79. package/lib/commands/init-wizard.js +302 -0
  80. package/lib/commands/init-wizard.js.map +1 -0
  81. package/lib/commands/init-workspace.d.ts +168 -0
  82. package/lib/commands/init-workspace.d.ts.map +1 -0
  83. package/lib/commands/init-workspace.js +372 -0
  84. package/lib/commands/init-workspace.js.map +1 -0
  85. package/lib/commands/init.d.ts +273 -0
  86. package/lib/commands/init.d.ts.map +1 -0
  87. package/lib/commands/init.js +303 -0
  88. package/lib/commands/init.js.map +1 -0
  89. package/lib/commands/lint-grammar-args.d.ts +23 -0
  90. package/lib/commands/lint-grammar-args.d.ts.map +1 -0
  91. package/lib/commands/lint-grammar-args.js +63 -0
  92. package/lib/commands/lint-grammar-args.js.map +1 -0
  93. package/lib/commands/lint-grammar-driver.d.ts +10 -0
  94. package/lib/commands/lint-grammar-driver.d.ts.map +1 -0
  95. package/lib/commands/lint-grammar-driver.js +72 -0
  96. package/lib/commands/lint-grammar-driver.js.map +1 -0
  97. package/lib/commands/lint-grammar-report.d.ts +24 -0
  98. package/lib/commands/lint-grammar-report.d.ts.map +1 -0
  99. package/lib/commands/lint-grammar-report.js +65 -0
  100. package/lib/commands/lint-grammar-report.js.map +1 -0
  101. package/lib/commands/lint-grammar.d.ts +33 -0
  102. package/lib/commands/lint-grammar.d.ts.map +1 -0
  103. package/lib/commands/lint-grammar.js +42 -0
  104. package/lib/commands/lint-grammar.js.map +1 -0
  105. package/lib/commands/measure-memory-args.d.ts +23 -0
  106. package/lib/commands/measure-memory-args.d.ts.map +1 -0
  107. package/lib/commands/measure-memory-args.js +106 -0
  108. package/lib/commands/measure-memory-args.js.map +1 -0
  109. package/lib/commands/measure-memory-driver.d.ts +10 -0
  110. package/lib/commands/measure-memory-driver.d.ts.map +1 -0
  111. package/lib/commands/measure-memory-driver.js +110 -0
  112. package/lib/commands/measure-memory-driver.js.map +1 -0
  113. package/lib/commands/measure-memory.d.ts +68 -0
  114. package/lib/commands/measure-memory.d.ts.map +1 -0
  115. package/lib/commands/measure-memory.js +100 -0
  116. package/lib/commands/measure-memory.js.map +1 -0
  117. package/lib/commands/model-docs-args.d.ts +23 -0
  118. package/lib/commands/model-docs-args.d.ts.map +1 -0
  119. package/lib/commands/model-docs-args.js +53 -0
  120. package/lib/commands/model-docs-args.js.map +1 -0
  121. package/lib/commands/model-docs-driver.d.ts +10 -0
  122. package/lib/commands/model-docs-driver.d.ts.map +1 -0
  123. package/lib/commands/model-docs-driver.js +61 -0
  124. package/lib/commands/model-docs-driver.js.map +1 -0
  125. package/lib/commands/model-docs-report.d.ts +18 -0
  126. package/lib/commands/model-docs-report.d.ts.map +1 -0
  127. package/lib/commands/model-docs-report.js +123 -0
  128. package/lib/commands/model-docs-report.js.map +1 -0
  129. package/lib/commands/model-docs.d.ts +30 -0
  130. package/lib/commands/model-docs.d.ts.map +1 -0
  131. package/lib/commands/model-docs.js +37 -0
  132. package/lib/commands/model-docs.js.map +1 -0
  133. package/lib/commands/projects-args.d.ts +25 -0
  134. package/lib/commands/projects-args.d.ts.map +1 -0
  135. package/lib/commands/projects-args.js +49 -0
  136. package/lib/commands/projects-args.js.map +1 -0
  137. package/lib/commands/projects.d.ts +45 -0
  138. package/lib/commands/projects.d.ts.map +1 -0
  139. package/lib/commands/projects.js +39 -0
  140. package/lib/commands/projects.js.map +1 -0
  141. package/lib/commands/query-args.d.ts +25 -0
  142. package/lib/commands/query-args.d.ts.map +1 -0
  143. package/lib/commands/query-args.js +46 -0
  144. package/lib/commands/query-args.js.map +1 -0
  145. package/lib/commands/query.d.ts +32 -0
  146. package/lib/commands/query.d.ts.map +1 -0
  147. package/lib/commands/query.js +29 -0
  148. package/lib/commands/query.js.map +1 -0
  149. package/lib/commands/reflect-args.d.ts +23 -0
  150. package/lib/commands/reflect-args.d.ts.map +1 -0
  151. package/lib/commands/reflect-args.js +59 -0
  152. package/lib/commands/reflect-args.js.map +1 -0
  153. package/lib/commands/reflect-driver.d.ts +10 -0
  154. package/lib/commands/reflect-driver.d.ts.map +1 -0
  155. package/lib/commands/reflect-driver.js +66 -0
  156. package/lib/commands/reflect-driver.js.map +1 -0
  157. package/lib/commands/reflect-report.d.ts +20 -0
  158. package/lib/commands/reflect-report.d.ts.map +1 -0
  159. package/lib/commands/reflect-report.js +127 -0
  160. package/lib/commands/reflect-report.js.map +1 -0
  161. package/lib/commands/reflect.d.ts +30 -0
  162. package/lib/commands/reflect.d.ts.map +1 -0
  163. package/lib/commands/reflect.js +38 -0
  164. package/lib/commands/reflect.js.map +1 -0
  165. package/lib/commands/save-args.d.ts +26 -0
  166. package/lib/commands/save-args.d.ts.map +1 -0
  167. package/lib/commands/save-args.js +59 -0
  168. package/lib/commands/save-args.js.map +1 -0
  169. package/lib/commands/save.d.ts +38 -0
  170. package/lib/commands/save.d.ts.map +1 -0
  171. package/lib/commands/save.js +48 -0
  172. package/lib/commands/save.js.map +1 -0
  173. package/lib/commands/validate-args.d.ts +23 -0
  174. package/lib/commands/validate-args.d.ts.map +1 -0
  175. package/lib/commands/validate-args.js +60 -0
  176. package/lib/commands/validate-args.js.map +1 -0
  177. package/lib/commands/validate-driver.d.ts +10 -0
  178. package/lib/commands/validate-driver.d.ts.map +1 -0
  179. package/lib/commands/validate-driver.js +72 -0
  180. package/lib/commands/validate-driver.js.map +1 -0
  181. package/lib/commands/validate-report.d.ts +30 -0
  182. package/lib/commands/validate-report.d.ts.map +1 -0
  183. package/lib/commands/validate-report.js +87 -0
  184. package/lib/commands/validate-report.js.map +1 -0
  185. package/lib/commands/validate.d.ts +34 -0
  186. package/lib/commands/validate.d.ts.map +1 -0
  187. package/lib/commands/validate.js +41 -0
  188. package/lib/commands/validate.js.map +1 -0
  189. package/lib/commands/watch-args.d.ts +26 -0
  190. package/lib/commands/watch-args.d.ts.map +1 -0
  191. package/lib/commands/watch-args.js +59 -0
  192. package/lib/commands/watch-args.js.map +1 -0
  193. package/lib/commands/watch.d.ts +75 -0
  194. package/lib/commands/watch.d.ts.map +1 -0
  195. package/lib/commands/watch.js +112 -0
  196. package/lib/commands/watch.js.map +1 -0
  197. package/lib/index.d.ts +16 -0
  198. package/lib/index.d.ts.map +1 -0
  199. package/lib/index.js +16 -0
  200. package/lib/index.js.map +1 -0
  201. package/lib/log-level.d.ts +39 -0
  202. package/lib/log-level.d.ts.map +1 -0
  203. package/lib/log-level.js +47 -0
  204. package/lib/log-level.js.map +1 -0
  205. package/lib/package-name.d.ts +33 -0
  206. package/lib/package-name.d.ts.map +1 -0
  207. package/lib/package-name.js +37 -0
  208. package/lib/package-name.js.map +1 -0
  209. package/lib/spawn-data-server.d.ts +102 -0
  210. package/lib/spawn-data-server.d.ts.map +1 -0
  211. package/lib/spawn-data-server.js +154 -0
  212. package/lib/spawn-data-server.js.map +1 -0
  213. package/lib/testing/echo-server.d.ts +11 -0
  214. package/lib/testing/echo-server.d.ts.map +1 -0
  215. package/lib/testing/echo-server.js +79 -0
  216. package/lib/testing/echo-server.js.map +1 -0
  217. package/package.json +105 -0
  218. package/src/cli-version.ts +32 -0
  219. package/src/cli.ts +139 -0
  220. package/src/commands/analyze-heap-args.ts +184 -0
  221. package/src/commands/analyze-heap.ts +76 -0
  222. package/src/commands/ast-ground-truth-args.ts +63 -0
  223. package/src/commands/ast-ground-truth-driver.ts +81 -0
  224. package/src/commands/ast-ground-truth.ts +49 -0
  225. package/src/commands/generate-transfer-model-args.ts +203 -0
  226. package/src/commands/generate-transfer-model-config.ts +128 -0
  227. package/src/commands/generate-transfer-model.ts +716 -0
  228. package/src/commands/harness-args.ts +429 -0
  229. package/src/commands/headless-harness.ts +165 -0
  230. package/src/commands/init-args.ts +273 -0
  231. package/src/commands/init-prompt.ts +149 -0
  232. package/src/commands/init-templates.ts +2037 -0
  233. package/src/commands/init-wizard.ts +378 -0
  234. package/src/commands/init-workspace.ts +442 -0
  235. package/src/commands/init.ts +554 -0
  236. package/src/commands/lint-grammar-args.ts +79 -0
  237. package/src/commands/lint-grammar-driver.ts +83 -0
  238. package/src/commands/lint-grammar-report.ts +74 -0
  239. package/src/commands/lint-grammar.ts +57 -0
  240. package/src/commands/measure-memory-args.ts +123 -0
  241. package/src/commands/measure-memory-driver.ts +128 -0
  242. package/src/commands/measure-memory.ts +143 -0
  243. package/src/commands/model-docs-args.ts +69 -0
  244. package/src/commands/model-docs-driver.ts +68 -0
  245. package/src/commands/model-docs-report.ts +133 -0
  246. package/src/commands/model-docs.ts +48 -0
  247. package/src/commands/projects-args.ts +62 -0
  248. package/src/commands/projects.ts +76 -0
  249. package/src/commands/query-args.ts +61 -0
  250. package/src/commands/query.ts +58 -0
  251. package/src/commands/reflect-args.ts +75 -0
  252. package/src/commands/reflect-driver.ts +76 -0
  253. package/src/commands/reflect-report.ts +138 -0
  254. package/src/commands/reflect.ts +51 -0
  255. package/src/commands/save-args.ts +74 -0
  256. package/src/commands/save.ts +84 -0
  257. package/src/commands/validate-args.ts +76 -0
  258. package/src/commands/validate-driver.ts +84 -0
  259. package/src/commands/validate-report.ts +95 -0
  260. package/src/commands/validate.ts +58 -0
  261. package/src/commands/watch-args.ts +74 -0
  262. package/src/commands/watch.ts +185 -0
  263. package/src/index.ts +16 -0
  264. package/src/log-level.ts +49 -0
  265. package/src/package-name.ts +39 -0
  266. package/src/spawn-data-server.ts +247 -0
  267. package/src/testing/echo-server.ts +93 -0
@@ -0,0 +1,429 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * The argv machinery the subcommands share, and the error vocabulary they all
12
+ * speak.
13
+ *
14
+ * Its own module rather than functions in the CLI entry point, because the entry
15
+ * point runs `main()` on import: a test that wants to read an argv the way a
16
+ * command does would otherwise have to spawn the whole binary, so flag handling
17
+ * and exit codes could only be covered through a subprocess.
18
+ *
19
+ * The messages here are a contract — scripts and CI steps match on them — so
20
+ * changing one is a breaking change for a caller nobody in this repo can see.
21
+ *
22
+ * Every parser takes an `onError` defaulting to report-and-exit, which is what a
23
+ * command line wants; a caller that would rather handle the problem passes its
24
+ * own and the parser hands control to it instead of ending the process.
25
+ *
26
+ * Each subcommand lives in a sibling `<name>-args` module exporting three things
27
+ * a test can hold against each other: its flag list, its `--help` text as data,
28
+ * and the parser that turns an argv into the runner's options. Help that drifts
29
+ * from the parser is worse than none — it reads as authoritative, so a flag
30
+ * missing from it is a capability nobody finds, and one it describes but the
31
+ * parser rejects is an invocation that fails for no visible reason.
32
+ */
33
+
34
+ import type { LogThreshold } from '@hydranium/protocol';
35
+ import { statSync } from 'node:fs';
36
+ import * as path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { parseLogLevelOption } from '../log-level.js';
39
+
40
+ /**
41
+ * How a parser reports a usage problem. It never returns, so a parser can treat
42
+ * the call as a dead end and keep narrowing the value it was about to reject.
43
+ */
44
+ export type UsageError = (message: string) => never;
45
+
46
+ /**
47
+ * The command-line failure mode: report the usage problem and stop.
48
+ *
49
+ * Exit 2, not 1: a CI step that treats "non-zero" as failure is unaffected, but
50
+ * one that reads 1 as "the workspace has errors" can tell that apart from "I
51
+ * passed a bad flag" only while the two codes differ. 2 is also what the
52
+ * bundled heap analyzer exits with for a usage error.
53
+ */
54
+ export function exitWithUsage(message: string): never {
55
+ console.error(message);
56
+ process.exit(2);
57
+ }
58
+
59
+ /** The value flag {@link parseHarnessArgs} consumes itself, before any the subcommand declares. */
60
+ export const SERVICES_FLAG = '--services';
61
+
62
+ /**
63
+ * The one spelling of the log threshold, shared by both subcommand families.
64
+ *
65
+ * Named rather than repeated because the two families reach the same variable by
66
+ * different routes — a spawned data-server for one, a spawned driver child for
67
+ * the other — and a second spelling would make the CLI's verbosity control depend
68
+ * on which command a caller happened to reach for.
69
+ */
70
+ export const LOG_LEVEL_FLAG = '--log-level';
71
+
72
+ /** The value flags {@link parseServerSpawnOptions} consumes itself. */
73
+ export const SERVER_SPAWN_VALUE_FLAGS: readonly string[] = ['--server', '--cwd', LOG_LEVEL_FLAG];
74
+
75
+ /**
76
+ * The `--help` line every subcommand describes {@link LOG_LEVEL_FLAG} with.
77
+ *
78
+ * Shared text, per-command padding: `command-args.test.ts` compares the flags a
79
+ * help block DESCRIBES against the flags the parser accepts, and it recognises a
80
+ * description by the two-space indent — so the line has to be a real option entry
81
+ * in each block, and the column each block aligns its descriptions at is the
82
+ * block's own.
83
+ */
84
+ export function logLevelHelpLine(padTo: number): string {
85
+ return ` ${`${LOG_LEVEL_FLAG} <lvl>`.padEnd(padTo)}Log threshold for the head this boots (off|error|warn|info|debug|trace).`;
86
+ }
87
+
88
+ /**
89
+ * Narrow a raw {@link LOG_LEVEL_FLAG} value that {@link parseHarnessArgs}
90
+ * collected, leaving an absent flag absent.
91
+ *
92
+ * Throws rather than reaching `onError`, matching {@link parseServerSpawnOptions}:
93
+ * the level vocabulary belongs to the protocol package, which reports it, and the
94
+ * entry point's catch turns that into the same message-and-exit.
95
+ */
96
+ export function logLevelOption(value: string | undefined): LogThreshold | undefined {
97
+ return value === undefined ? undefined : parseLogLevelOption(value);
98
+ }
99
+
100
+ /**
101
+ * Whether the argv asks for the command's help rather than describing a run.
102
+ *
103
+ * `valueFlags` are the flags that take a value, and passing them is what makes
104
+ * the answer correct rather than approximately correct: a token sitting in a
105
+ * VALUE position is data, not a request. Scanning the whole argv for `--help`
106
+ * instead means `--content --help` prints help and exits 0 — so a caller writing
107
+ * that literal text saves nothing and reads success. Omit them only for a
108
+ * command whose flags take no values at all.
109
+ */
110
+ export function helpRequested(args: readonly string[], valueFlags: readonly string[] = []): boolean {
111
+ const valueSet = new Set(valueFlags);
112
+ for (let index = 0; index < args.length; index += 1) {
113
+ const token = args[index];
114
+ if (token === '--help' || token === '-h') {
115
+ return true;
116
+ }
117
+ if (valueSet.has(token)) {
118
+ index += 1;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Print a help block on stdout, not stderr: `<cmd> --help` succeeded, and a
126
+ * caller piping it into a pager reads the stream a successful command writes to.
127
+ */
128
+ export function printHelp(lines: readonly string[]): void {
129
+ for (const line of lines) {
130
+ console.log(line);
131
+ }
132
+ }
133
+
134
+ /** What {@link parseHarnessArgs} recovered from an argv. */
135
+ export interface ParsedHarnessArgs {
136
+ servicesModule: string;
137
+ /**
138
+ * An absolute directory path, resolved by {@link resolveWorkspaceArgument} and
139
+ * checked to exist. `''` when the subcommand declared `requireWorkspace: false`.
140
+ */
141
+ workspace: string;
142
+ /** Value flags by flag name; a bool flag that appeared reads `'true'`. */
143
+ options: Record<string, string | undefined>;
144
+ /** Repeatable value flags by flag name, in the order they appeared. */
145
+ values: Record<string, string[]>;
146
+ }
147
+
148
+ /** Per-subcommand deviations from {@link parseHarnessArgs}'s defaults. */
149
+ export interface HarnessArgsConfig {
150
+ /**
151
+ * `false` for subcommands that take no workspace: a positional is then
152
+ * rejected as unexpected, and the returned `workspace` is `''`.
153
+ */
154
+ requireWorkspace?: boolean;
155
+ /**
156
+ * Value flags that may appear more than once. Each occurrence's value is
157
+ * collected into `values[flag]` rather than overwriting the single-valued
158
+ * `options[flag]`.
159
+ */
160
+ repeatableValueFlags?: readonly string[];
161
+ onError?: UsageError;
162
+ }
163
+
164
+ /**
165
+ * Shared parser for the headless-harness subcommands. Consumes the required
166
+ * `--services <module>` value flag and a single positional `<workspace>`, plus
167
+ * the `valueFlags` (each takes a value) and `boolFlags` (presence-only, recorded
168
+ * as `'true'`) the subcommand recognises. Unknown flags or a missing
169
+ * `--services`/`<workspace>` are usage errors, and so is a `<workspace>` that
170
+ * reaches no directory — see {@link resolveWorkspaceArgument} for why that cannot
171
+ * be left to the run.
172
+ */
173
+ export function parseHarnessArgs(
174
+ args: string[],
175
+ commandName: string,
176
+ valueFlags: readonly string[],
177
+ boolFlags: readonly string[],
178
+ config: HarnessArgsConfig = {}
179
+ ): ParsedHarnessArgs {
180
+ const requireWorkspace = config.requireWorkspace ?? true;
181
+ const onError = config.onError ?? exitWithUsage;
182
+ const valueSet = new Set(valueFlags);
183
+ const boolSet = new Set(boolFlags);
184
+ const repeatableSet = new Set(config.repeatableValueFlags ?? []);
185
+ const options: Record<string, string | undefined> = {};
186
+ const values: Record<string, string[]> = {};
187
+ let servicesModule: string | undefined;
188
+ let workspace: string | undefined;
189
+ for (let index = 0; index < args.length; index += 1) {
190
+ const flag = args[index];
191
+ const next = (): string => {
192
+ const value = args[index + 1];
193
+ if (value === undefined) {
194
+ onError(`Missing value for ${flag}`);
195
+ }
196
+ index += 1;
197
+ return value;
198
+ };
199
+ if (flag === '--services') {
200
+ servicesModule = next();
201
+ } else if (boolSet.has(flag)) {
202
+ options[flag] = 'true';
203
+ } else if (repeatableSet.has(flag)) {
204
+ (values[flag] ??= []).push(next());
205
+ } else if (valueSet.has(flag)) {
206
+ options[flag] = next();
207
+ } else if (flag.startsWith('--')) {
208
+ onError(`Unknown option: ${flag} (hydranium-cli ${commandName} --help)`);
209
+ } else if (requireWorkspace && workspace === undefined) {
210
+ workspace = flag;
211
+ } else {
212
+ onError(`Unexpected argument: ${flag} (hydranium-cli ${commandName} --help)`);
213
+ }
214
+ }
215
+ return {
216
+ servicesModule: assertRequired(servicesModule, '--services', commandName, onError),
217
+ workspace: requireWorkspace
218
+ ? resolveWorkspaceArgument(assertRequired(workspace, '<workspace>', commandName, onError), commandName, onError)
219
+ : (workspace ?? ''),
220
+ options,
221
+ values
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Resolve the `<workspace>` positional to an absolute directory path, reporting
227
+ * a usage error when it reaches anything else.
228
+ *
229
+ * The check is here rather than left to the traversal because reaching no
230
+ * workspace fails NOWHERE downstream: the directory read's `ENOENT` is swallowed
231
+ * inside a `Promise.all`, the run then reports whatever documents the head
232
+ * contributes independently of the workspace, and the gate exits 0 — so a CI step
233
+ * whose path has rotted reads as a clean workspace rather than a broken
234
+ * invocation. The surviving document count is a property of the head and can be
235
+ * non-zero, so no caller can use the number as the tell.
236
+ *
237
+ * A `file:` URI is converted to a path rather than passed on, because the
238
+ * headless seams resolve a string workspace as a filesystem path — an
239
+ * unconverted URI becomes `<cwd>/file:/…` and reaches nothing, by the same silent
240
+ * route.
241
+ *
242
+ * A directory is required: a path naming a file traverses to nothing just as
243
+ * quietly. An EMPTY directory is accepted — a workspace with no documents
244
+ * validating clean is an answer, not a bad argument, and the binary is
245
+ * language-agnostic so it cannot judge whether the files present are the
246
+ * language's.
247
+ */
248
+ export function resolveWorkspaceArgument(workspace: string, commandName: string, onError: UsageError = exitWithUsage): string {
249
+ const resolved = toWorkspacePath(workspace, commandName, onError);
250
+ const help = `(hydranium-cli ${commandName} --help)`;
251
+ // `throwIfNoEntry: false` suppresses ENOENT and nothing else, so a path the
252
+ // OS cannot even interrogate still throws raw. A `file://host/share` URI
253
+ // reaches this on Windows, where it converts to a UNC path rather than
254
+ // being rejected as unusable, and an unreachable host then surfaces as
255
+ // `UNKNOWN: unknown error` instead of a usage message naming the argument.
256
+ let stats;
257
+ try {
258
+ stats = statSync(resolved, { throwIfNoEntry: false });
259
+ } catch (err: unknown) {
260
+ const detail = err instanceof Error ? err.message : String(err);
261
+ onError(`<workspace> is not reachable: '${workspace}' resolved to ${resolved} (${detail}) ${help}`);
262
+ }
263
+ if (stats === undefined) {
264
+ onError(`<workspace> does not exist: '${workspace}' resolved to ${resolved}, which is not a path on this machine ${help}`);
265
+ }
266
+ if (!stats.isDirectory()) {
267
+ onError(`<workspace> is not a directory: '${workspace}' resolved to ${resolved}. Pass the directory holding the models ${help}`);
268
+ }
269
+ return resolved;
270
+ }
271
+
272
+ function toWorkspacePath(workspace: string, commandName: string, onError: UsageError): string {
273
+ if (!/^file:/i.test(workspace)) {
274
+ return path.resolve(workspace);
275
+ }
276
+ try {
277
+ return fileURLToPath(workspace);
278
+ } catch (err: unknown) {
279
+ const detail = err instanceof Error ? err.message : String(err);
280
+ onError(`<workspace> is not a usable file URI: '${workspace}' (${detail}) (hydranium-cli ${commandName} --help)`);
281
+ }
282
+ }
283
+
284
+ /** Demand a value the subcommand cannot run without, naming the help that lists it. */
285
+ export function assertRequired<T>(value: T | undefined, flag: string, command: string, onError: UsageError = exitWithUsage): T {
286
+ if (value === undefined) {
287
+ onError(`Missing required option: ${flag} (hydranium-cli ${command} --help)`);
288
+ }
289
+ return value;
290
+ }
291
+
292
+ /** Parse a numeric CLI option; a usage error on a non-numeric value, `undefined` when absent. */
293
+ export function numericOption(value: string | undefined, flag: string, onError: UsageError = exitWithUsage): number | undefined {
294
+ if (value === undefined) {
295
+ return undefined;
296
+ }
297
+ const parsed = Number(value);
298
+ if (!Number.isFinite(parsed)) {
299
+ onError(`Option ${flag} expects a number, got: ${value}`);
300
+ }
301
+ return parsed;
302
+ }
303
+
304
+ /**
305
+ * Parse the remaining `extra` tokens after {@link parseServerSpawnOptions}
306
+ * consumed the shared flags. Maps `--<flag>` (kebab) to a `flag` property
307
+ * (camelCase, leading `--` stripped). Every token must be a recognised flag
308
+ * followed by its value — unrecognised ones are a usage error, which saves a
309
+ * silent typo from reading as an omitted option.
310
+ */
311
+ export function parseFlagOptions(
312
+ extra: string[],
313
+ commandName: string,
314
+ recognised: readonly string[],
315
+ onError: UsageError = exitWithUsage
316
+ ): Record<string, string | undefined> {
317
+ const recognisedSet = new Set(recognised);
318
+ const out: Record<string, string | undefined> = {};
319
+ for (let index = 0; index < extra.length; index += 1) {
320
+ const flag = extra[index];
321
+ if (!recognisedSet.has(flag)) {
322
+ onError(`Unknown option: ${flag} (hydranium-cli ${commandName} --help)`);
323
+ }
324
+ const value = extra[index + 1];
325
+ if (value === undefined) {
326
+ onError(`Missing value for ${flag}`);
327
+ }
328
+ out[kebabToCamel(flag.slice(2))] = value;
329
+ index += 1;
330
+ }
331
+ return out;
332
+ }
333
+
334
+ function kebabToCamel(name: string): string {
335
+ return name.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
336
+ }
337
+
338
+ /** What {@link parseServerSpawnOptions} recovered from an argv. */
339
+ export interface SpawnOptions {
340
+ serverCommand: string;
341
+ serverArgs: string[];
342
+ cwd?: string;
343
+ logLevel?: LogThreshold;
344
+ /** The tokens this parser did not claim, for the subcommand's own options. */
345
+ extra: string[];
346
+ }
347
+
348
+ /**
349
+ * Whether a `--server` token names a path the shell would resolve against the
350
+ * working directory.
351
+ *
352
+ * Deliberately narrow: only an explicit `./` or `../` prefix, plus a COMMAND
353
+ * that carries a separator without being absolute. A bare `models` or a
354
+ * `--stdio` must not match — the point is to recognise the spellings that are
355
+ * unambiguously filesystem-relative, not to guess which arguments are paths.
356
+ */
357
+ function isRelativePathToken(token: string, isCommand: boolean): boolean {
358
+ if (token.startsWith('./') || token.startsWith('../') || token.startsWith('.\\') || token.startsWith('..\\')) {
359
+ return true;
360
+ }
361
+ return isCommand && !path.isAbsolute(token) && /[/\\]/.test(token);
362
+ }
363
+
364
+ /**
365
+ * Shared option parser for subcommands that spawn a data-server child. Consumes
366
+ * `--server` / `--cwd` / `--log-level` and returns the surviving tokens as
367
+ * `extra` for {@link parseFlagOptions} to finish.
368
+ *
369
+ * An unrecognised `--log-level` value throws rather than reaching `onError`: the
370
+ * level vocabulary belongs to the protocol package, which reports it, and the
371
+ * entry point's catch turns that into the same message-and-exit.
372
+ *
373
+ * **`--cwd` together with a relative `--server` path is refused, not repaired.**
374
+ * `--cwd` re-roots the CHILD, so the shell's own reading of `./lib/entry.js` —
375
+ * relative to where the user stands — is not what the child gets, and the failure
376
+ * arrives as a bare interpreter module-not-found that names neither flag. Refusing
377
+ * is the honest half of the choice: re-rooting the token against the parent would
378
+ * be friendlier for the entry path and would silently change what an argument
379
+ * meant to be read by the child resolves to, which is the same class of quiet
380
+ * misdirection one layer along. An absolute path, or dropping `--cwd` and passing
381
+ * the workspace to the entry, both say what they mean.
382
+ */
383
+ export function parseServerSpawnOptions(args: string[], commandName: string, onError: UsageError = exitWithUsage): SpawnOptions {
384
+ let serverSpec: string | undefined;
385
+ let cwd: string | undefined;
386
+ let logLevel: LogThreshold | undefined;
387
+ const extra: string[] = [];
388
+ for (let index = 0; index < args.length; index += 1) {
389
+ const flag = args[index];
390
+ const claim = (): string => {
391
+ const value = args[index + 1];
392
+ if (value === undefined) {
393
+ onError(`Missing value for ${flag}`);
394
+ }
395
+ index += 1;
396
+ return value;
397
+ };
398
+ if (flag === '--server') {
399
+ serverSpec = claim();
400
+ } else if (flag === '--cwd') {
401
+ cwd = claim();
402
+ } else if (flag === LOG_LEVEL_FLAG) {
403
+ logLevel = parseLogLevelOption(claim());
404
+ } else {
405
+ extra.push(flag);
406
+ }
407
+ }
408
+ if (!serverSpec) {
409
+ onError(`Missing required option: --server (hydranium-cli ${commandName} --help)`);
410
+ }
411
+ // Split the spec on whitespace — quoting is the caller's shell's job;
412
+ // by the time we see the value here it's already a single token.
413
+ const tokens = serverSpec.split(/\s+/).filter(token => token.length > 0);
414
+ if (tokens.length === 0) {
415
+ onError('Empty --server value');
416
+ }
417
+ const [serverCommand, ...serverArgs] = tokens;
418
+ if (cwd !== undefined) {
419
+ const relative = tokens.find((token, position) => isRelativePathToken(token, position === 0));
420
+ if (relative !== undefined) {
421
+ onError(
422
+ `--cwd re-roots the spawned child, so the relative path '${relative}' in --server would be resolved ` +
423
+ `against '${cwd}' rather than against the current directory. Pass an absolute path, or drop --cwd and ` +
424
+ `give the workspace to the entry as its own argument (hydranium-cli ${commandName} --help)`
425
+ );
426
+ }
427
+ }
428
+ return { serverCommand, serverArgs, cwd, logLevel, extra };
429
+ }
@@ -0,0 +1,165 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import type * as HydraniumCoreNode from '@hydranium/core/node';
11
+ import type { LogThreshold } from '@hydranium/protocol';
12
+ import { spawn } from 'node:child_process';
13
+ import { statSync, writeFileSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import * as path from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ import { logLevelEnv } from '../log-level.js';
18
+ import { SERVICES_FLAG } from './harness-args.js';
19
+
20
+ /** The flag every report-producing subcommand names its destination file with. */
21
+ export const OUT_FILE_FLAG = '--out-file';
22
+
23
+ /** The `@hydranium/core/node` module surface the drivers use (type only). */
24
+ type CoreNodeModule = typeof HydraniumCoreNode;
25
+
26
+ /**
27
+ * The zero-arg service factory a head exports for the headless harness
28
+ * subcommands (`measure-memory`, `ast-ground-truth`). The head wires its own
29
+ * filesystem inside.
30
+ */
31
+ export type ServicesFactory = HydraniumCoreNode.MeasureModelMemoryOptions['createServices'];
32
+
33
+ /** What {@link loadHeadlessContext} hands a driver: the head's factory + harness. */
34
+ export interface HeadlessContext {
35
+ /** The head's zero-arg `createServices(): { shared }` thunk. */
36
+ createServices: ServicesFactory;
37
+ /** `@hydranium/core/node`, resolved from the HEAD's dependency graph. */
38
+ coreNode: CoreNodeModule;
39
+ }
40
+
41
+ /**
42
+ * Child-side: load everything a driver needs from a head's `--services` module —
43
+ * its `createServices` thunk AND the `@hydranium/core/node` harness, both
44
+ * resolved from the HEAD's location rather than the CLI's.
45
+ *
46
+ * The module is an ESM file (typically a compiled `lib/*.js`) exporting a
47
+ * ZERO-ARG `createServices(): { shared }`. `hydranium-cli` is language-agnostic
48
+ * and cannot statically import a head's `create<Lang>Services`, so the dynamic
49
+ * import is the seam that keeps the binary head-neutral. Resolving the harness
50
+ * from the head's graph (via `createRequire` rooted at the head module) means the
51
+ * CLI itself needs no `@hydranium/core` dependency — the head always has it — and
52
+ * guarantees the harness runs against the SAME core copy as `createServices`.
53
+ *
54
+ * Throws a clear error when the path names no file, and when the export is
55
+ * missing or not a function.
56
+ */
57
+ export async function loadHeadlessContext(servicesModule: string): Promise<HeadlessContext> {
58
+ const modulePath = path.resolve(servicesModule);
59
+ // Checked before the import, not caught after it: Node's
60
+ // ERR_MODULE_NOT_FOUND names neither the flag nor this file's role, and a
61
+ // catch could not tell a missing --services module from a missing dependency
62
+ // OF that module, which deserves Node's message verbatim.
63
+ const stats = statSync(modulePath, { throwIfNoEntry: false });
64
+ if (stats === undefined || !stats.isFile()) {
65
+ throw new Error(
66
+ `${SERVICES_FLAG} must name an existing ESM file; '${servicesModule}' resolved to ${modulePath}, ` +
67
+ `which is not one. Pass the head's COMPILED entry, e.g. \`${SERVICES_FLAG} ./lib/services.js\` — ` +
68
+ 'not the TypeScript source and not a package name.'
69
+ );
70
+ }
71
+ const moduleUrl = pathToFileURL(modulePath).href;
72
+ const imported = (await import(moduleUrl)) as Record<string, unknown>;
73
+ const createServices = imported.createServices;
74
+ if (typeof createServices !== 'function') {
75
+ throw new Error(
76
+ `Services module '${servicesModule}' must export a zero-arg 'createServices(): { shared }' ` +
77
+ `function (found ${typeof createServices}). The head wires its own filesystem inside, ` +
78
+ 'e.g. `export const createServices = () => createMyLangServices({ ...NodeFileSystem })`.'
79
+ );
80
+ }
81
+ const headRequire = createRequire(moduleUrl);
82
+ const coreNodePath = headRequire.resolve('@hydranium/core/node');
83
+ const coreNode = (await import(pathToFileURL(coreNodePath).href)) as CoreNodeModule;
84
+ return { createServices: createServices as ServicesFactory, coreNode };
85
+ }
86
+
87
+ /**
88
+ * Child-side: deliver a finished report — to `outFile` when one was named, else
89
+ * to stdout.
90
+ *
91
+ * The file is created only once the report EXISTS, which is what the flag buys
92
+ * over the shell redirection it replaces: `> report.md` truncates the destination
93
+ * before the command runs, so a head that throws while booting leaves a
94
+ * zero-length file that a later step reads as an empty report rather than as a
95
+ * failed run.
96
+ */
97
+ export function emitReport(report: string, outFile: string | undefined): void {
98
+ if (outFile === undefined) {
99
+ console.log(report);
100
+ return;
101
+ }
102
+ writeFileSync(outFile, report.endsWith('\n') ? report : `${report}\n`);
103
+ // Progress on stderr, so a caller that also captures stdout gets a clean
104
+ // stream rather than a note where the report used to be.
105
+ console.error(`Wrote ${outFile}`);
106
+ }
107
+
108
+ /**
109
+ * Parent-side: spawn `node <execArgs...>` inheriting stdio and resolve with the
110
+ * child's exit code. The harness subcommands need a child process anyway — the
111
+ * heads' services and a workspace build run in isolation, and `measure-memory`
112
+ * additionally needs `--expose-gc` for post-GC readings.
113
+ *
114
+ * `env` is MERGED over the parent's rather than replacing it: the child resolves
115
+ * the head's module graph, so dropping `PATH` / `NODE_OPTIONS` / the platform's
116
+ * own variables would break the import the driver exists to perform.
117
+ */
118
+ export function spawnNodeChild(execArgs: string[], env?: Record<string, string>): Promise<number> {
119
+ return new Promise((resolve, reject) => {
120
+ const child = spawn(process.execPath, execArgs, {
121
+ stdio: 'inherit',
122
+ env: env === undefined ? process.env : { ...process.env, ...env }
123
+ });
124
+ child.on('error', reject);
125
+ child.on('close', code => resolve(code ?? 0));
126
+ });
127
+ }
128
+
129
+ /** How a subcommand parent reaches its driver child; the seam `__spawnForTest` replaces. */
130
+ export type SpawnDriverChild = (execArgs: string[], env?: Record<string, string>) => Promise<number>;
131
+
132
+ /** What every `--services` subcommand carries for the child it spawns, on top of its own options. */
133
+ export interface DriverSpawnOptions {
134
+ /**
135
+ * Log threshold for the head the driver boots. Absent leaves the child's
136
+ * inherited environment alone, so an ambient `HYDRANIUM_LOG_LEVEL` still wins
137
+ * where a caller set one.
138
+ */
139
+ readonly logLevel?: LogThreshold;
140
+ /** Test-only: capture the node argv and env instead of spawning the real child. */
141
+ readonly __spawnForTest?: SpawnDriverChild;
142
+ }
143
+
144
+ /**
145
+ * Parent-side: run a subcommand's driver child and propagate its exit code, so a
146
+ * shell or CI step sees the gate.
147
+ *
148
+ * **The log threshold travels in the child's ENVIRONMENT, not its argv.** The
149
+ * head's logger reads `HYDRANIUM_LOG_LEVEL` while `createServices` constructs it,
150
+ * which is inside the driver's dynamic import — a flag the driver parsed would
151
+ * arrive after the only moment it can be read. Setting it here rather than
152
+ * exporting it also leaves the CLI's own output alone, unlike an ambient
153
+ * `HYDRANIUM_LOG_LEVEL=…` the parent would carry too.
154
+ *
155
+ * A head that binds a logger of its own decides for itself whether the flag means
156
+ * anything, which is the intended seam: the CLI is language-agnostic and cannot
157
+ * reach past `createServices`.
158
+ */
159
+ export async function runDriverChild(execArgs: string[], options: DriverSpawnOptions): Promise<void> {
160
+ const spawnChild = options.__spawnForTest ?? spawnNodeChild;
161
+ const code = await spawnChild(execArgs, options.logLevel === undefined ? undefined : logLevelEnv(options.logLevel));
162
+ if (code) {
163
+ process.exitCode = code;
164
+ }
165
+ }