@dzhechkov/harness-cli 0.4.6 → 0.5.0

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * `@dzhechkov/harness-core` compatibility guard — the import-free island.
3
+ *
4
+ * ## Why this module exists
5
+ *
6
+ * ESM resolves and link-checks **every named binding of the whole static graph** before
7
+ * the first byte of user code runs. `bin.ts` used to `import { runCli } from './cli.js'`
8
+ * statically, and `cli.ts` statically imports ~100 names from `@dzhechkov/harness-core`.
9
+ * Against a cached lower core the linker threw
10
+ * `SyntaxError: … does not provide an export named 'GRADE_SUCCESS_FLOOR'` at
11
+ * `#asyncInstantiate` — every subcommand, `--version` included, died before any guard
12
+ * could speak. (MEASURED against `@dzhechkov/harness-core@0.4.1`; `^0.4.0` legally
13
+ * resolves it and the symbol only exists from core 0.4.2.)
14
+ *
15
+ * ## The defining constraint — negative, and enforced rather than remembered
16
+ *
17
+ * **This file may not import anything from `@dzhechkov/harness-core`, directly or
18
+ * transitively — only `node:` builtins.** If it joins the graph it is guarding, it dies
19
+ * with it and the guard is decorative. `test/core-compat-guard.test.ts` parses this
20
+ * file's import list and asserts exactly that.
21
+ *
22
+ * ## Fail-open on an unreadable version — a deliberate exception
23
+ *
24
+ * When the installed core's version cannot be determined the guard **proceeds**. Its job
25
+ * is to replace a bad message with a good one, not to gate execution; blocking a working
26
+ * install because a version string was unreadable would be a self-inflicted outage
27
+ * strictly worse than the `SyntaxError`. This is a conscious exception to the house rule
28
+ * "inconclusive never passes", which governs gates that grant a *quality* verdict — do
29
+ * not "fix" it. The symmetric hazard (a guard that fires wrongly and bricks a good
30
+ * install) is covered by {@link compareSemver} treating any unparseable component as
31
+ * compatible.
32
+ *
33
+ * @packageDocumentation
34
+ */
35
+
36
+ import { existsSync, readFileSync } from 'node:fs';
37
+ import { dirname, join, resolve } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ /**
41
+ * The lowest `@dzhechkov/harness-core` this CLI build can run against.
42
+ *
43
+ * Kept equal to the minimum of the declared dependency range in `package.json` — a
44
+ * standing test (`test/core-import-floor.test.ts`, F1) fails if the two drift apart,
45
+ * because a guard that says 0.4.7 while npm may install 0.4.2 is worse than no guard.
46
+ */
47
+ export const MIN_CORE = '0.5.0';
48
+
49
+ /** The npm name of the guarded package — one literal, used by every leg below. */
50
+ export const CORE_PACKAGE_NAME = '@dzhechkov/harness-core';
51
+
52
+ /**
53
+ * Compare the dot-separated identifiers of two SemVer prerelease strings (§11.4).
54
+ *
55
+ * Numeric identifiers compare numerically and rank BELOW alphanumeric ones; when every
56
+ * shared identifier is equal, the longer list is the higher version.
57
+ */
58
+ function comparePrerelease(a: string, b: string): -1 | 0 | 1 {
59
+ if (a === b) return 0;
60
+ const xs = a.split('.');
61
+ const ys = b.split('.');
62
+ for (let i = 0; i < Math.max(xs.length, ys.length); i += 1) {
63
+ const x = xs[i];
64
+ const y = ys[i];
65
+ // A shorter identifier list is the LOWER version (`1.0.0-rc` < `1.0.0-rc.1`).
66
+ if (x === undefined) return -1;
67
+ if (y === undefined) return 1;
68
+ const xNum = /^\d+$/.test(x);
69
+ const yNum = /^\d+$/.test(y);
70
+ if (xNum && yNum) {
71
+ const nx = Number(x);
72
+ const ny = Number(y);
73
+ if (nx !== ny) return nx < ny ? -1 : 1;
74
+ continue;
75
+ }
76
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
77
+ if (xNum !== yNum) return xNum ? -1 : 1;
78
+ if (x !== y) return x < y ? -1 : 1;
79
+ }
80
+ return 0;
81
+ }
82
+
83
+ /**
84
+ * Compare two semver strings by `major.minor.patch`, then by prerelease.
85
+ *
86
+ * Rules that matter here:
87
+ * - every component runs through a `Number.isFinite` clamp; a non-finite or missing
88
+ * component makes the comparison return `0` (compatible), never a false "too old";
89
+ * - **prerelease (`-…`) and build metadata (`+…`) are parsed SEPARATELY**, because they
90
+ * mean opposite things. A prerelease sorts BELOW its release (`0.4.6-rc.1` < `0.4.6`),
91
+ * so a release-candidate core does not silently satisfy a floor it has not reached;
92
+ * build metadata is **ignored entirely** (`0.4.6+sha1234` === `0.4.6`), because a core
93
+ * built from a tagged commit is that version and refusing it would be a self-inflicted
94
+ * outage — exactly what ADR-003 driver D6 forbids.
95
+ *
96
+ * Both halves were wrong before fix round 1 (QE F3): one capture group `(?:[-+](.*))?`
97
+ * swallowed either sigil, patched over by `!m[4].startsWith('build')`. MEASURED
98
+ * consequences: `0.4.6+sha1234` was REFUSED (false block) and `0.4.6-build.5` was
99
+ * ACCEPTED (false pass) against a `0.4.6` floor. A string special case is not SemVer.
100
+ */
101
+ export function compareSemver(a: string, b: string): -1 | 0 | 1 {
102
+ const parse = (v: string): { nums: number[]; pre: string | null } | null => {
103
+ // Prerelease and build metadata get one capture EACH, in SemVer's own order.
104
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?\s*$/.exec(v);
105
+ if (m === null) return null;
106
+ const nums = [Number(m[1]), Number(m[2]), Number(m[3])];
107
+ if (!nums.every((n) => Number.isFinite(n))) return null;
108
+ // m[5] (build metadata) is deliberately never read: SemVer §10 says it is not
109
+ // part of precedence.
110
+ return { nums, pre: m[4] !== undefined && m[4].length > 0 ? m[4] : null };
111
+ };
112
+ const pa = parse(a);
113
+ const pb = parse(b);
114
+ // Unparseable on either side ⇒ "compatible": never brick a working install on a
115
+ // version string we failed to read.
116
+ if (pa === null || pb === null) return 0;
117
+ for (let i = 0; i < 3; i += 1) {
118
+ const x = pa.nums[i] ?? 0;
119
+ const y = pb.nums[i] ?? 0;
120
+ if (x < y) return -1;
121
+ if (x > y) return 1;
122
+ }
123
+ if (pa.pre === null && pb.pre === null) return 0;
124
+ if (pa.pre === null) return 1;
125
+ if (pb.pre === null) return -1;
126
+ return comparePrerelease(pa.pre, pb.pre);
127
+ }
128
+
129
+ /** Read `.version` out of a `package.json`, or `null` if it is missing/unreadable. */
130
+ function readVersion(packageJsonPath: string): string | null {
131
+ try {
132
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: unknown };
133
+ return typeof parsed.version === 'string' ? parsed.version : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** Walk up from `start` to the nearest enclosing `package.json`, reading its version. */
140
+ function versionFromEnclosingPackage(start: string): string | null {
141
+ let dir = start;
142
+ for (;;) {
143
+ const candidate = join(dir, 'package.json');
144
+ if (existsSync(candidate)) {
145
+ const version = readVersion(candidate);
146
+ if (version !== null) return version;
147
+ }
148
+ const parent = dirname(dir);
149
+ if (parent === dir) return null;
150
+ dir = parent;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Best-effort: what version of `@dzhechkov/harness-core` will actually be loaded?
156
+ *
157
+ * Three legs, in order — the obvious one does **not** work on its own:
158
+ *
159
+ * 1. `import.meta.resolve(CORE_PACKAGE_NAME)` (a function on node ≥20.6; `typeof`-guarded
160
+ * because `engines` says `>=20` and it is flagged on 20.0–20.5), then walk up to the
161
+ * nearest `package.json`.
162
+ * 2. Ancestor-walk from this file for `node_modules/@dzhechkov/harness-core/package.json`
163
+ * — **no resolver semantics**. This leg is load-bearing rather than a fallback: an old
164
+ * core's `exports` map declares only `{".": {"import": …}}`, so
165
+ * `require.resolve('@dzhechkov/harness-core/package.json')` fails with
166
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` (MEASURED in this tree) — and the old core is
167
+ * precisely the case the guard exists for.
168
+ * 3. `null` — the fail-open path documented at the top of this file.
169
+ */
170
+ export function resolveInstalledCoreVersion(fromUrl: string): string | null {
171
+ const meta = import.meta as unknown as { resolve?: (specifier: string) => string };
172
+ if (typeof meta.resolve === 'function') {
173
+ try {
174
+ const resolved = meta.resolve(CORE_PACKAGE_NAME);
175
+ const version = versionFromEnclosingPackage(dirname(fileURLToPath(resolved)));
176
+ if (version !== null) return version;
177
+ } catch {
178
+ /* fall through to the filesystem walk */
179
+ }
180
+ }
181
+
182
+ let dir: string;
183
+ try {
184
+ dir = dirname(fileURLToPath(fromUrl));
185
+ } catch {
186
+ dir = resolve('.');
187
+ }
188
+ for (;;) {
189
+ const candidate = join(dir, 'node_modules', ...CORE_PACKAGE_NAME.split('/'), 'package.json');
190
+ if (existsSync(candidate)) {
191
+ const version = readVersion(candidate);
192
+ if (version !== null) return version;
193
+ }
194
+ const parent = dirname(dir);
195
+ if (parent === dir) return null;
196
+ dir = parent;
197
+ }
198
+ }
199
+
200
+ /** The verdict of {@link checkCoreCompat}. */
201
+ export type CoreCompatVerdict = { readonly ok: true } | { readonly ok: false; readonly message: string };
202
+
203
+ /**
204
+ * Decide whether the found core is usable, and if not, say so in words a user can act on.
205
+ *
206
+ * `found === null` ⇒ `{ ok: true }` (fail-open — see the module doc comment).
207
+ */
208
+ export function checkCoreCompat(input: { found: string | null; min: string }): CoreCompatVerdict {
209
+ const { found, min } = input;
210
+ if (found === null) return { ok: true };
211
+ if (compareSemver(found, min) >= 0) return { ok: true };
212
+ return {
213
+ ok: false,
214
+ message: [
215
+ `dz: needs ${CORE_PACKAGE_NAME} >= ${min}, found ${found}`,
216
+ ' Your resolver reused a cached lower core. Fix: rm -rf ~/.npm/_npx && npx @dzhechkov/harness-cli@latest --version',
217
+ ` (or: npm i -D ${CORE_PACKAGE_NAME}@latest)`,
218
+ ].join('\n'),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Reactive translator for the failure the pre-emptive guard could not see.
224
+ *
225
+ * If the version probe fails open (leg 3) and the dynamic import then dies on a missing
226
+ * ESM binding, the raw `SyntaxError` reads like a corrupted install or a Node problem.
227
+ * Rewrite it into the same named form. Returns `null` for any OTHER error — the caller
228
+ * must re-throw those unchanged, so real bugs still surface.
229
+ */
230
+ export function describeMissingExportError(error: unknown, found: string | null): string | null {
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ const match = /does not provide an export named '?([A-Za-z0-9_$]+)'?/.exec(message);
233
+ if (match === null) return null;
234
+ return [
235
+ `dz: this build needs ${CORE_PACKAGE_NAME} >= ${MIN_CORE}${found !== null ? `, found ${found}` : ''}`,
236
+ ` The installed core does not export ${JSON.stringify(match[1] ?? '')}, which this CLI imports.`,
237
+ ' Fix: rm -rf ~/.npm/_npx && npx @dzhechkov/harness-cli@latest --version',
238
+ ` (or: npm i -D ${CORE_PACKAGE_NAME}@latest)`,
239
+ ].join('\n');
240
+ }