@indigoai-us/hq-cli 5.109.11 → 5.109.13
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.
- package/CHANGELOG.md +42 -0
- package/dist/main.js +12 -9
- package/dist/sentry.js +4 -2
- package/dist/startup-registration.d.ts +3 -1
- package/dist/startup-registration.js +25 -7
- package/dist/utils/incomplete-install-error.d.ts +40 -10
- package/dist/utils/incomplete-install-error.js +174 -49
- package/dist/utils/install-tree-torn.d.ts +94 -4
- package/dist/utils/install-tree-torn.js +299 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.13] — 2026-09-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A background reinstall no longer turns into a crash report in the CommonJS twin
|
|
10
|
+
of the shape 5.109.12 fixed for ES modules (HQ-CLI-20). When another program
|
|
11
|
+
reinstalls hq globally while a command is starting, it rewrites hq's files one
|
|
12
|
+
by one over a few seconds. If a bundled CommonJS dependency (here `ajv`, reached
|
|
13
|
+
while `hq core timeout-guard` registers its commands) had just resolved one of
|
|
14
|
+
its own files and that file vanished before Node read it, the low-level `ENOENT …
|
|
15
|
+
open` came from inside Node's CommonJS module loader — a shape the recovery only
|
|
16
|
+
recognized for ES modules, so it was still filed as a crash. It is now recognized
|
|
17
|
+
as "your install was mid-update, not an hq bug": the command waits for the
|
|
18
|
+
reinstall to settle and re-runs once on the healed tree, and the same noise is
|
|
19
|
+
dropped on the routes that bypass the top-level handler. The gate is deliberately
|
|
20
|
+
narrow — it fires only when Node's own loader was reading a module's source (the
|
|
21
|
+
frame directly beneath the file read is a `cjs/loader` frame, never the
|
|
22
|
+
dependency's own code) — so a third-party package whose own startup code reads a
|
|
23
|
+
missing file, or a fault in hq's own shipped files, still reports exactly once
|
|
24
|
+
(Sentry HQ-CLI-20).
|
|
25
|
+
|
|
26
|
+
## [5.109.12] — 2026-09-12
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A background reinstall no longer turns into a crash report in two more shapes
|
|
31
|
+
it was still slipping through (HQ-CLI-1S, HQ-CLI-1T). When another program
|
|
32
|
+
reinstalls hq globally while a command is starting, it renames hq's files aside
|
|
33
|
+
and rewrites them file by file over a few seconds. Two failure shapes from that
|
|
34
|
+
window were still filed as crashes instead of being recovered: (1) a file that
|
|
35
|
+
Node checked a moment earlier vanished between its two resolve steps, surfacing
|
|
36
|
+
as a low-level `ENOENT … lstat` from inside the module resolver (HQ-CLI-1S);
|
|
37
|
+
and (2) a dependency file that was found but only half-written, so loading it
|
|
38
|
+
returned an empty module and its own code threw `TypeError: … is not a
|
|
39
|
+
function` (HQ-CLI-1T). Both are now recognized as "your install was mid-update,
|
|
40
|
+
not an hq bug": the command waits for the reinstall to settle and re-runs once
|
|
41
|
+
on the healed tree instead of reporting an error. The half-written-file case
|
|
42
|
+
only ever waits when a reinstall is actually in progress — a genuine, lasting
|
|
43
|
+
defect in a third-party dependency still reports exactly once and never pays a
|
|
44
|
+
wait, and a fault in hq's own shipped files is still reported. (The two related
|
|
45
|
+
shapes HQ-CLI-1V and HQ-CLI-1W are already covered by 5.109.8 and #561.)
|
|
46
|
+
|
|
5
47
|
## [5.109.11] — 2026-09-12
|
|
6
48
|
|
|
7
49
|
### Fixed
|
package/dist/main.js
CHANGED
|
@@ -565,8 +565,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
565
565
|
// install left a bundled file unwritten (HQ-CLI-1N, a CJS relative-sibling
|
|
566
566
|
// MODULE_NOT_FOUND; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND for a
|
|
567
567
|
// dependency the requiring third-party package's own manifest declares), or
|
|
568
|
-
// a concurrent global install rewrote the running tree so
|
|
569
|
-
//
|
|
568
|
+
// a concurrent global install rewrote the running tree so a module present
|
|
569
|
+
// at resolve was gone at read (HQ-CLI-1M, an esm-loader ENOENT; HQ-CLI-20,
|
|
570
|
+
// its CJS loader-read twin).
|
|
570
571
|
// All carry no hq-cli frames and reached the final else, filing a bare
|
|
571
572
|
// crash and an unactionable line. An incomplete install is the caller's
|
|
572
573
|
// machine, the disposition HQ-CLI-Y already established for the qmd CHILD —
|
|
@@ -574,11 +575,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
574
575
|
// environmental family (after the typed qmd carriers and the hq state-write
|
|
575
576
|
// carrier, before environmentalFsErrorMessage): the signatures are disjoint
|
|
576
577
|
// — ENVIRONMENTAL_FS_CODES is only ENOSPC/EDQUOT/EROFS (never
|
|
577
|
-
// ENOENT/MODULE_NOT_FOUND), no qmd carrier sets requireStack or
|
|
578
|
-
//
|
|
579
|
-
// <packageRoot>/node_modules — so ordering changes nothing that
|
|
578
|
+
// ENOENT/MODULE_NOT_FOUND), no qmd carrier sets requireStack or a loader
|
|
579
|
+
// source-read frame (esm/load or cjs/loader), and the classified file must
|
|
580
|
+
// sit under <packageRoot>/node_modules — so ordering changes nothing that
|
|
581
|
+
// exists. The
|
|
580
582
|
// UNATTRIBUTABLE shapes are deliberately NOT suppressed and are captured
|
|
581
|
-
// WITH bounded context on the generic path below:
|
|
583
|
+
// WITH bounded context on the generic path below: a loader/resolver ENOENT
|
|
582
584
|
// whose path did not survive, and a bare miss the requirer does NOT declare
|
|
583
585
|
// (a possible undeclared/peer dependency, now named in the context).
|
|
584
586
|
const incompleteInstallMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
|
|
@@ -679,9 +681,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
679
681
|
// A genuinely unclassified fault is still captured exactly once. Two
|
|
680
682
|
// shapes attach bounded, hq-derived context so the next occurrence
|
|
681
683
|
// carries the evidence this one lacked: a qmd spawn-level failure the fix
|
|
682
|
-
// could not attribute (HQ-CLI-1A), and
|
|
683
|
-
// did not survive delivery (HQ-CLI-1M
|
|
684
|
-
//
|
|
684
|
+
// could not attribute (HQ-CLI-1A), and a loader/resolver ENOENT whose path
|
|
685
|
+
// did not survive delivery (HQ-CLI-1M esm-load, HQ-CLI-1S resolve-time,
|
|
686
|
+
// HQ-CLI-20 CJS load-time — the unattributable incomplete-install shape).
|
|
687
|
+
// Every other error captures bare, exactly as before.
|
|
685
688
|
const spawnContext = qmdSpawnFailureCaptureContext(err);
|
|
686
689
|
const installContext = incompleteInstallCaptureContext(err);
|
|
687
690
|
if (spawnContext || installContext) {
|
package/dist/sentry.js
CHANGED
|
@@ -37,7 +37,9 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
37
37
|
// (HQ-CLI-1N, a CJS relative-sibling MODULE_NOT_FOUND under its bundled
|
|
38
38
|
// node_modules; HQ-CLI-1Q, a CJS BARE-specifier MODULE_NOT_FOUND the requiring
|
|
39
39
|
// third-party package's own manifest declares; HQ-CLI-1M, an esm-loader ENOENT
|
|
40
|
-
// for a file present at resolve and gone at read
|
|
40
|
+
// for a file present at resolve and gone at read; HQ-CLI-20, its CJS loader-read
|
|
41
|
+
// twin — a bundled CJS dep gone between resolve and the loader's source read).
|
|
42
|
+
// handleTopLevelError already
|
|
41
43
|
// prints the reinstall remedy for the top-level route; dropping the event here
|
|
42
44
|
// suppresses the fatal regardless of route — the unhandled-rejection boundary,
|
|
43
45
|
// the command-level captureException sites, and bin/hq-auth-refresh —
|
|
@@ -50,7 +52,7 @@ export function epipeAwareBeforeSend(event, hint) {
|
|
|
50
52
|
if (incompleteInstallMessage(hint?.originalException))
|
|
51
53
|
return null;
|
|
52
54
|
// An incomplete-install shape that is NOT suppressed (a bare miss the requirer
|
|
53
|
-
// does not declare, or a path-less
|
|
55
|
+
// does not declare, or a path-less loader/resolver ENOENT) should still arrive
|
|
54
56
|
// ATTRIBUTABLE on every capture route, not only the top-level boundary:
|
|
55
57
|
// handleTopLevelError in main.ts is the ONLY caller of
|
|
56
58
|
// incompleteInstallCaptureContext, so events reaching this hook directly — the
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* style of handleTopLevelError so the whole seam is unit-testable without real
|
|
17
17
|
* spawns, waits, or a real install tree.
|
|
18
18
|
*/
|
|
19
|
-
import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
|
|
19
|
+
import { type InstallRewriteSignalArgs, type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
|
|
20
20
|
/**
|
|
21
21
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
22
22
|
* from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
|
|
@@ -70,6 +70,8 @@ export interface RegisterRecoveryDeps {
|
|
|
70
70
|
deriveInstallRoot?: () => string | null;
|
|
71
71
|
lockPath?: () => string;
|
|
72
72
|
waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
|
|
73
|
+
/** Positive writer-signal probe for the eval-throw gate (defaults to installRewriteInProgress). */
|
|
74
|
+
rewriteInProgress?: (args: InstallRewriteSignalArgs) => boolean;
|
|
73
75
|
/** node flags to forward to the re-exec child (defaults to process.execArgv). */
|
|
74
76
|
execArgv?: readonly string[];
|
|
75
77
|
}
|
|
@@ -20,7 +20,7 @@ import { spawnSync } from "node:child_process";
|
|
|
20
20
|
import { resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
21
|
import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
|
|
22
22
|
import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
|
|
23
|
-
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
23
|
+
import { classifyEvalThrow, classifyModuleNotFound, installRewriteInProgress, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
24
24
|
/**
|
|
25
25
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
26
26
|
* from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
|
|
@@ -87,12 +87,32 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
87
87
|
return {};
|
|
88
88
|
}
|
|
89
89
|
catch (err) {
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
90
|
+
// The packageRoot bounds the eval-throw classifier's throw site and feeds the
|
|
91
|
+
// settle wait's manifest-health / retired-sibling guards; resolved once here.
|
|
92
|
+
const packageRoot = resolvePackageRoot(deps);
|
|
93
|
+
// Classify the loader failure. classifyModuleNotFound covers the resolve/load
|
|
94
|
+
// dialects (incl. HQ-CLI-1S resolve-enoent); classifyEvalThrow covers a
|
|
95
|
+
// module-EVALUATION throw (HQ-CLI-1T). Anything unclassified is rethrown as the
|
|
96
|
+
// SAME object to the existing boundary, preserving today's behaviour exactly.
|
|
97
|
+
const classified = classifyModuleNotFound(err) ?? classifyEvalThrow(err, packageRoot);
|
|
93
98
|
if (!classified)
|
|
94
99
|
throw err;
|
|
95
|
-
const
|
|
100
|
+
const lockPath = (deps.lockPath ?? updateLockPath)();
|
|
101
|
+
const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
|
|
102
|
+
// Writer-signal gate for the eval-throw dialect ONLY. A module-evaluation
|
|
103
|
+
// throw is indistinguishable from a persistent third-party load-time defect
|
|
104
|
+
// unless a global reinstall is provably in progress, so require a POSITIVE
|
|
105
|
+
// writer signal — a fresh foreign lock, an absent/name-mismatched manifest, or
|
|
106
|
+
// a retired `.hq-cli-<hash>` sibling — before entering recovery. With no such
|
|
107
|
+
// signal the SAME error is rethrown raw (today's immediate capture) and never
|
|
108
|
+
// pays a settle wait; a benign fs error (an unreadable manifest or unlistable
|
|
109
|
+
// parent) is deliberately NOT read as a signal, so a real defect never waits.
|
|
110
|
+
// The loader-race dialects need no such gate. Injected for a hermetic seam.
|
|
111
|
+
if (classified.dialect === "eval-throw") {
|
|
112
|
+
const rewriteInProgress = deps.rewriteInProgress ?? installRewriteInProgress;
|
|
113
|
+
if (!rewriteInProgress({ packageRoot, lockPath }))
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
96
116
|
// A child that already recovered must never wait or re-exec again: report
|
|
97
117
|
// once and let the boundary capture it.
|
|
98
118
|
if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
|
|
@@ -108,8 +128,6 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
108
128
|
});
|
|
109
129
|
}
|
|
110
130
|
stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
|
|
111
|
-
const lockPath = (deps.lockPath ?? updateLockPath)();
|
|
112
|
-
const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
|
|
113
131
|
const result = await waitFor({
|
|
114
132
|
target: classified.target,
|
|
115
133
|
packageRoot,
|
|
@@ -13,10 +13,11 @@ export type PackageRootResolver = () => string | null;
|
|
|
13
13
|
/** Which strategy produced the running install's root, recorded in diagnostics. */
|
|
14
14
|
export type PackageRootResolverSource = "manifest-walk" | "string-derivation" | "unresolved" | "injected";
|
|
15
15
|
/**
|
|
16
|
-
* If `err` is an in-process incomplete-install module-load failure —
|
|
17
|
-
*
|
|
18
|
-
* (HQ-CLI-
|
|
19
|
-
* — return the actionable, input-free reinstall
|
|
16
|
+
* If `err` is an in-process incomplete-install module-load failure — the CJS
|
|
17
|
+
* relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
|
|
18
|
+
* or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
|
|
19
|
+
* under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
|
|
20
|
+
* remedy; otherwise return null.
|
|
20
21
|
*
|
|
21
22
|
* Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
|
|
22
23
|
* the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
|
|
@@ -24,7 +25,11 @@ export type PackageRootResolverSource = "manifest-walk" | "string-derivation" |
|
|
|
24
25
|
* fails yields null.
|
|
25
26
|
*/
|
|
26
27
|
export declare function incompleteInstallMessage(err: unknown, resolvePackageRoot?: PackageRootResolver, fileSystem?: Pick<typeof fs, "readFileSync">): string | null;
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Bounded, scrubber-safe diagnostics for an unattributable loader/resolver ENOENT
|
|
30
|
+
* whose `path` did not survive delivery — the esm-loader open shape (HQ-CLI-1M) or
|
|
31
|
+
* the resolve-time lstat shape (HQ-CLI-1S). The two frame booleans say which.
|
|
32
|
+
*/
|
|
28
33
|
export type IncompleteInstallEsmDiagnostics = {
|
|
29
34
|
/** The resolved install root, or the literal `<unresolved>` when none was found. */
|
|
30
35
|
packageRoot: string;
|
|
@@ -41,9 +46,30 @@ export type IncompleteInstallEsmDiagnostics = {
|
|
|
41
46
|
*/
|
|
42
47
|
packageJsonExists?: boolean;
|
|
43
48
|
nodeModulesExists?: boolean;
|
|
49
|
+
/** The stack carried an esm-loader (source-read) frame — the HQ-CLI-1M route. */
|
|
44
50
|
esmLoaderFrame: boolean;
|
|
51
|
+
/** The stack carried a module-resolver frame — the HQ-CLI-1S resolve-time route. */
|
|
52
|
+
resolverFrame: boolean;
|
|
53
|
+
/** The stack carried a CJS loader (source-read) frame — the HQ-CLI-20 route. */
|
|
54
|
+
cjsLoaderFrame: boolean;
|
|
45
55
|
code: string;
|
|
46
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Bounded, scrubber-safe diagnostics for a module-EVALUATION throw that reached
|
|
59
|
+
* the boundary raw (HQ-CLI-1T) — a code-less TypeError/ReferenceError/SyntaxError
|
|
60
|
+
* whose throw site sits under the running install's node_modules, so the next
|
|
61
|
+
* occurrence names the offending third-party package instead of filing bare. Only
|
|
62
|
+
* the closed error name and the bounded package name are recorded; never the
|
|
63
|
+
* message, argv, or a full path.
|
|
64
|
+
*/
|
|
65
|
+
export type IncompleteInstallEvalThrowDiagnostics = {
|
|
66
|
+
evalThrow: true;
|
|
67
|
+
errorName: string;
|
|
68
|
+
throwSitePackage: string;
|
|
69
|
+
packageRoot: string;
|
|
70
|
+
packageRootResolved: boolean;
|
|
71
|
+
resolver: PackageRootResolverSource;
|
|
72
|
+
};
|
|
47
73
|
/**
|
|
48
74
|
* Bounded, scrubber-safe diagnostics for a NOT-suppressed third-party bare miss
|
|
49
75
|
* (HQ-CLI-1Q): a possible undeclared / peer-only dependency defect, made
|
|
@@ -82,7 +108,7 @@ export type IncompleteInstallRelativeDiagnostics = {
|
|
|
82
108
|
* exactly one of the strict shapes above; the looseness is only at the read
|
|
83
109
|
* boundary.
|
|
84
110
|
*/
|
|
85
|
-
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics>;
|
|
111
|
+
export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnostics & IncompleteInstallBareDiagnostics & IncompleteInstallRelativeDiagnostics & IncompleteInstallEvalThrowDiagnostics>;
|
|
86
112
|
/**
|
|
87
113
|
* When an incomplete-install failure reaches the capture path WITHOUT being
|
|
88
114
|
* suppressed, return a bounded `contexts.incomplete_install` block so the next
|
|
@@ -94,10 +120,14 @@ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnosti
|
|
|
94
120
|
* dist/ or assets/, outside the install, or under an unresolved root
|
|
95
121
|
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
96
122
|
* packageRootResolved, resolver, requirerScope };
|
|
97
|
-
* -
|
|
98
|
-
*
|
|
99
|
-
* nor node_system_error carried a `path` →
|
|
100
|
-
* nodeModulesExists, esmLoaderFrame,
|
|
123
|
+
* - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
|
|
124
|
+
* esm-load open, HQ-CLI-1S resolve-time lstat, HQ-CLI-20 CJS load-time open) —
|
|
125
|
+
* where neither the exception value nor node_system_error carried a `path` →
|
|
126
|
+
* { packageRoot, packageJsonExists, nodeModulesExists, esmLoaderFrame,
|
|
127
|
+
* resolverFrame, cjsLoaderFrame, code };
|
|
128
|
+
* - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
|
|
129
|
+
* { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
|
|
130
|
+
* resolver }.
|
|
101
131
|
* Built with the byte-capped, scrubber-safe discipline of
|
|
102
132
|
* package-root-diagnostics.ts — never a caller argv, query, or user-minted
|
|
103
133
|
* value. Never throws. main.ts attaches this on the generic capture path
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// failure inside THIS process rather than a qmd child's captured stderr, which
|
|
8
8
|
// is the gap HQ-CLI-Y's classifier cannot cover.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// Four shapes, one cause — a partial/interrupted global install left a file
|
|
11
11
|
// unwritten, or a concurrent global install rewrote the running tree
|
|
12
12
|
// underneath a command:
|
|
13
13
|
//
|
|
@@ -36,6 +36,14 @@
|
|
|
36
36
|
// ERR_MODULE_NOT_FOUND at resolve, never ENOENT at load; an ENOENT at load
|
|
37
37
|
// proves the file vanished between resolve and read.
|
|
38
38
|
//
|
|
39
|
+
// HQ-CLI-20 (Sentry 7724280783) — CJS load, in-process. The CJS twin of
|
|
40
|
+
// HQ-CLI-1M: `hq core timeout-guard` registers the full command graph, which
|
|
41
|
+
// reaches a bundled CommonJS dependency (ajv) whose internal require() resolved
|
|
42
|
+
// a sibling from Module._pathCache and then read its source after a concurrent
|
|
43
|
+
// reinstall had vanished it. Node raised `ENOENT` with syscall 'open' from the
|
|
44
|
+
// CJS loader's source read (defaultLoadImpl/loadSource) — a cjs/loader frame but
|
|
45
|
+
// NO esm/load frame, so the esm-loader gate missed it and it filed a bare crash.
|
|
46
|
+
//
|
|
39
47
|
// All shapes carry no hq-cli frames, reach the boundary's final `else`, and —
|
|
40
48
|
// before this classifier — filed a bare captureException plus an unactionable
|
|
41
49
|
// `hq: <fallback>` line. The disposition is the one HQ-CLI-Y already
|
|
@@ -59,12 +67,15 @@
|
|
|
59
67
|
// optional-dependency miss (not guaranteed installed), and an undeclared or
|
|
60
68
|
// peer-only miss (`Cannot find module 'mqtt'` from a requirer that does not
|
|
61
69
|
// declare it) all stay loud — the last two enriched with both package names.
|
|
62
|
-
// 3. The
|
|
63
|
-
// `
|
|
70
|
+
// 3. The loader shapes additionally require a loader SOURCE-READ frame — the ESM
|
|
71
|
+
// shape an `esm/load` frame, the CJS shape (HQ-CLI-20) a cjs/loader frame
|
|
72
|
+
// immediately beneath the node:fs read. An ordinary `fs.readFileSync` ENOENT
|
|
73
|
+
// written by hq's own code, or one escaping a third-party module's own
|
|
74
|
+
// EVALUATION, carries no such frame and stays captured.
|
|
64
75
|
import * as fs from "fs";
|
|
65
76
|
import * as path from "path";
|
|
66
77
|
import { packageRoot, stringDerivedPackageRoot } from "./hq-roots.js";
|
|
67
|
-
import { packageNameOf } from "./install-tree-torn.js";
|
|
78
|
+
import { evalThrowShape, hasCjsLoaderSourceReadFrame, packageNameOf } from "./install-tree-torn.js";
|
|
68
79
|
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
69
80
|
/**
|
|
70
81
|
* The actionable remedy shown to the operator. Input-free — nothing from the
|
|
@@ -95,6 +106,16 @@ const RELATIVE_SPECIFIER = /^\.\.?[\\/]/;
|
|
|
95
106
|
* `esm/loader` module.
|
|
96
107
|
*/
|
|
97
108
|
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
109
|
+
/**
|
|
110
|
+
* The Node module RESOLVER frame — `finalizeResolution` in
|
|
111
|
+
* `node:internal/modules/esm/resolve`, or the CJS `Module._findPath → toRealPath`
|
|
112
|
+
* route in `node:internal/modules/cjs/loader`. Alongside `syscall === 'lstat'`
|
|
113
|
+
* this proves a resolve-time ENOENT (HQ-CLI-1S) — a path component that vanished
|
|
114
|
+
* between the resolver's stat and its realpathSync lstat — so an ordinary
|
|
115
|
+
* `fs.lstatSync` ENOENT from hq's own code stays captured. Mirrors the frame
|
|
116
|
+
* matcher in install-tree-torn.ts (duplicated exactly as ESM_LOADER_FRAME is).
|
|
117
|
+
*/
|
|
118
|
+
const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
|
|
98
119
|
const ROOT_DIAGNOSTIC_BYTES = 256;
|
|
99
120
|
const CODE_DIAGNOSTIC_BYTES = 32;
|
|
100
121
|
// Package names live inside hq-cli's own dependency graph, so their universe is
|
|
@@ -306,6 +327,10 @@ function requirerDeclaresDependency(requiringFile, packageName, fileSystem) {
|
|
|
306
327
|
function hasEsmLoaderFrame(stack) {
|
|
307
328
|
return typeof stack === "string" && ESM_LOADER_FRAME.test(stack);
|
|
308
329
|
}
|
|
330
|
+
/** True when `stack` carries a Node module RESOLVER frame. */
|
|
331
|
+
function hasResolverFrame(stack) {
|
|
332
|
+
return typeof stack === "string" && RESOLVER_FRAME.test(stack);
|
|
333
|
+
}
|
|
309
334
|
/**
|
|
310
335
|
* The ESM-loader ENOENT SIGNATURE, independent of whether a usable `path`
|
|
311
336
|
* survived: `code === 'ENOENT'`, `syscall === 'open'`, and an esm-loader frame
|
|
@@ -321,10 +346,44 @@ function isEsmLoaderEnoent(err) {
|
|
|
321
346
|
hasEsmLoaderFrame(record.stack));
|
|
322
347
|
}
|
|
323
348
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
349
|
+
* The resolve-time ENOENT SIGNATURE (HQ-CLI-1S): `code === 'ENOENT'`,
|
|
350
|
+
* `syscall === 'lstat'`, and a resolver frame — independent of whether a usable
|
|
351
|
+
* `path` survived delivery. Sibling of {@link isEsmLoaderEnoent}; the message
|
|
352
|
+
* classifier additionally requires a `path` under node_modules.
|
|
353
|
+
*/
|
|
354
|
+
function isResolveEnoent(err) {
|
|
355
|
+
if (err === null || typeof err !== "object")
|
|
356
|
+
return false;
|
|
357
|
+
const record = err;
|
|
358
|
+
return (record.code === "ENOENT" &&
|
|
359
|
+
record.syscall === "lstat" &&
|
|
360
|
+
hasResolverFrame(record.stack));
|
|
361
|
+
}
|
|
362
|
+
/** True when `stack` is a CJS loader SOURCE-READ stack (HQ-CLI-20). */
|
|
363
|
+
function hasCjsLoaderFrame(stack) {
|
|
364
|
+
return typeof stack === "string" && hasCjsLoaderSourceReadFrame(stack);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The CJS loader-read ENOENT SIGNATURE (HQ-CLI-20): `code === 'ENOENT'`,
|
|
368
|
+
* `syscall === 'open'`, and a CJS loader SOURCE-READ stack (the positional gate
|
|
369
|
+
* shared from install-tree-torn.ts) — the CJS twin of {@link isEsmLoaderEnoent},
|
|
370
|
+
* independent of whether a usable `path` survived. The message classifier
|
|
371
|
+
* additionally requires a `path` under node_modules.
|
|
372
|
+
*/
|
|
373
|
+
function isCjsLoaderEnoent(err) {
|
|
374
|
+
if (err === null || typeof err !== "object")
|
|
375
|
+
return false;
|
|
376
|
+
const record = err;
|
|
377
|
+
return (record.code === "ENOENT" &&
|
|
378
|
+
record.syscall === "open" &&
|
|
379
|
+
hasCjsLoaderFrame(record.stack));
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* If `err` is an in-process incomplete-install module-load failure — the CJS
|
|
383
|
+
* relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
|
|
384
|
+
* or the resolve-time lstat shape (HQ-CLI-1S), with the failing file confirmed
|
|
385
|
+
* under `<packageRoot>/node_modules/` — return the actionable, input-free reinstall
|
|
386
|
+
* remedy; otherwise return null.
|
|
328
387
|
*
|
|
329
388
|
* Mirrors qmdModuleMissingMessage so the top-level handler and beforeSend branch
|
|
330
389
|
* the same way: a non-null result means print-the-remedy-and-skip-Sentry, null
|
|
@@ -375,17 +434,35 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
|
|
|
375
434
|
? INCOMPLETE_INSTALL_REMEDY
|
|
376
435
|
: null;
|
|
377
436
|
}
|
|
378
|
-
// Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module
|
|
379
|
-
//
|
|
380
|
-
if (record.syscall
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return null;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
437
|
+
// Shape B (ESM load, HQ-CLI-1M): an ENOENT from the module LOADER for a file
|
|
438
|
+
// present at resolve and gone at read (syscall 'open' + an esm-loader frame).
|
|
439
|
+
if (record.syscall === "open" &&
|
|
440
|
+
typeof record.path === "string" &&
|
|
441
|
+
hasEsmLoaderFrame(record.stack)) {
|
|
442
|
+
return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
|
|
443
|
+
}
|
|
444
|
+
// Shape B' (RESOLVE time, HQ-CLI-1S): an ENOENT from the resolver's realpath
|
|
445
|
+
// step (syscall 'lstat' + a resolver frame) — a path component that a concurrent
|
|
446
|
+
// reinstall renamed away between the resolver's stat and its lstat. Same
|
|
447
|
+
// disposition as B: a vanished component under the running install's
|
|
448
|
+
// node_modules is a torn install, so print the remedy and skip capture.
|
|
449
|
+
if (record.syscall === "lstat" &&
|
|
450
|
+
typeof record.path === "string" &&
|
|
451
|
+
hasResolverFrame(record.stack)) {
|
|
452
|
+
return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
|
|
453
|
+
}
|
|
454
|
+
// Shape B'' (CJS load-time, HQ-CLI-20): the CJS twin of Shape B — an ENOENT from
|
|
455
|
+
// the CJS loader's source read (syscall 'open' + a CJS loader SOURCE-READ stack,
|
|
456
|
+
// NO esm/load frame) for a bundled dependency file present at resolve and gone at
|
|
457
|
+
// read. Same disposition as B: a vanished file under the running install's
|
|
458
|
+
// node_modules is a torn install, so print the remedy and skip capture; a miss
|
|
459
|
+
// under the install's own dist/ or assets/ stays captured.
|
|
460
|
+
if (record.syscall === "open" &&
|
|
461
|
+
typeof record.path === "string" &&
|
|
462
|
+
hasCjsLoaderFrame(record.stack)) {
|
|
463
|
+
return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
389
466
|
}
|
|
390
467
|
/** A readFileSync surface, defaulting to the real fs when the caller injects none. */
|
|
391
468
|
function readFileFrom(fileSystem) {
|
|
@@ -513,10 +590,14 @@ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
|
|
|
513
590
|
* dist/ or assets/, outside the install, or under an unresolved root
|
|
514
591
|
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
515
592
|
* packageRootResolved, resolver, requirerScope };
|
|
516
|
-
* -
|
|
517
|
-
*
|
|
518
|
-
* nor node_system_error carried a `path` →
|
|
519
|
-
* nodeModulesExists, esmLoaderFrame,
|
|
593
|
+
* - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
|
|
594
|
+
* esm-load open, HQ-CLI-1S resolve-time lstat, HQ-CLI-20 CJS load-time open) —
|
|
595
|
+
* where neither the exception value nor node_system_error carried a `path` →
|
|
596
|
+
* { packageRoot, packageJsonExists, nodeModulesExists, esmLoaderFrame,
|
|
597
|
+
* resolverFrame, cjsLoaderFrame, code };
|
|
598
|
+
* - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
|
|
599
|
+
* { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
|
|
600
|
+
* resolver }.
|
|
520
601
|
* Built with the byte-capped, scrubber-safe discipline of
|
|
521
602
|
* package-root-diagnostics.ts — never a caller argv, query, or user-minted
|
|
522
603
|
* value. Never throws. main.ts attaches this on the generic capture path
|
|
@@ -531,19 +612,66 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
531
612
|
const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
|
|
532
613
|
if (relative)
|
|
533
614
|
return relative;
|
|
534
|
-
// (C) HQ-CLI-1M — the path-less
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
if (
|
|
540
|
-
|
|
615
|
+
// (C) HQ-CLI-1M / HQ-CLI-1S / HQ-CLI-20 — the path-less loader/resolver ENOENT
|
|
616
|
+
// (esm-load open, resolve-time lstat, or CJS load-time open), made attributable.
|
|
617
|
+
// An ENOENT whose `path` survived and sits under node_modules is classified and
|
|
618
|
+
// printed above, never captured; only the unattributable shape (no usable path,
|
|
619
|
+
// or a path outside node_modules) reaches here.
|
|
620
|
+
if (isEsmLoaderEnoent(err) || isResolveEnoent(err) || isCjsLoaderEnoent(err)) {
|
|
621
|
+
if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) === null) {
|
|
622
|
+
const record = err;
|
|
623
|
+
const code = typeof record.code === "string" ? record.code : "";
|
|
624
|
+
// The default resolver knows which strategy answered (manifest walk vs the
|
|
625
|
+
// string derivation that survives a torn tree); an injected resolver is
|
|
626
|
+
// opaque, so it is recorded as "injected" and its return used as the root.
|
|
627
|
+
const resolution = resolvePackageRoot === safePackageRoot
|
|
628
|
+
? resolvePackageRootWithSource()
|
|
629
|
+
: {
|
|
630
|
+
root: resolveRootSafely(resolvePackageRoot),
|
|
631
|
+
source: "injected",
|
|
632
|
+
};
|
|
633
|
+
const root = resolution.root;
|
|
634
|
+
const diagnostics = {
|
|
635
|
+
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
636
|
+
packageRootResolved: root !== null,
|
|
637
|
+
resolver: resolution.source,
|
|
638
|
+
// Which route raised it — the esm-loader (source-read) frame for 1M, the
|
|
639
|
+
// resolver frame for 1S, the CJS loader source-read frame for HQ-CLI-20;
|
|
640
|
+
// any are recorded from the stack.
|
|
641
|
+
esmLoaderFrame: hasEsmLoaderFrame(record.stack),
|
|
642
|
+
resolverFrame: hasResolverFrame(record.stack),
|
|
643
|
+
cjsLoaderFrame: hasCjsLoaderFrame(record.stack),
|
|
644
|
+
code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
|
|
645
|
+
};
|
|
646
|
+
// Report the existence booleans ONLY when a root was resolved. Reported for
|
|
647
|
+
// an unresolved root they were uninitialised `false`s indistinguishable from
|
|
648
|
+
// "resolved but missing" — the instrumentation defect the prior fix shipped.
|
|
649
|
+
if (root !== null) {
|
|
650
|
+
diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
|
|
651
|
+
diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
|
|
652
|
+
}
|
|
653
|
+
return { incomplete_install: diagnostics };
|
|
654
|
+
}
|
|
541
655
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
656
|
+
// (D) HQ-CLI-1T — a module-EVALUATION throw that reached the boundary raw (the
|
|
657
|
+
// writer-signal gate found no reinstall in progress), made attributable.
|
|
658
|
+
return evalThrowCaptureContext(err, resolvePackageRoot);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* When a module-EVALUATION throw (HQ-CLI-1T) reached the capture path raw — a
|
|
662
|
+
* code-less TypeError/ReferenceError/SyntaxError whose top-level code ran while a
|
|
663
|
+
* half-written dependency file returned an empty module — return a bounded
|
|
664
|
+
* `incomplete_install` block naming the offending third-party package so the next
|
|
665
|
+
* occurrence is attributable instead of bare; otherwise undefined. When the root
|
|
666
|
+
* resolves, the throw site MUST sit under its node_modules (an hq-cli `dist/` or
|
|
667
|
+
* `assets/` eval throw is hq's OWN defect and stays a bare capture); when the root
|
|
668
|
+
* is unresolved the block is still attached with `packageRootResolved:false`.
|
|
669
|
+
* Never throws; never emits the message, argv, or a full path.
|
|
670
|
+
*/
|
|
671
|
+
function evalThrowCaptureContext(err, resolvePackageRoot) {
|
|
672
|
+
const shape = evalThrowShape(err);
|
|
673
|
+
if (!shape)
|
|
674
|
+
return undefined;
|
|
547
675
|
const resolution = resolvePackageRoot === safePackageRoot
|
|
548
676
|
? resolvePackageRootWithSource()
|
|
549
677
|
: {
|
|
@@ -551,21 +679,18 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
551
679
|
source: "injected",
|
|
552
680
|
};
|
|
553
681
|
const root = resolution.root;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
682
|
+
if (root !== null && !isUnderNodeModules(shape.throwSite, root))
|
|
683
|
+
return undefined;
|
|
684
|
+
return {
|
|
685
|
+
incomplete_install: {
|
|
686
|
+
evalThrow: true,
|
|
687
|
+
errorName: boundedDiagnosticValue(shape.errorName, CODE_DIAGNOSTIC_BYTES),
|
|
688
|
+
throwSitePackage: boundedDiagnosticValue(requiringPackage(shape.throwSite)?.name ?? "<unresolved>", PACKAGE_NAME_DIAGNOSTIC_BYTES),
|
|
689
|
+
packageRoot: boundedDiagnosticValue(root ?? "<unresolved>", ROOT_DIAGNOSTIC_BYTES),
|
|
690
|
+
packageRootResolved: root !== null,
|
|
691
|
+
resolver: resolution.source,
|
|
692
|
+
},
|
|
560
693
|
};
|
|
561
|
-
// Report the existence booleans ONLY when a root was resolved. Reported for an
|
|
562
|
-
// unresolved root they were uninitialised `false`s indistinguishable from
|
|
563
|
-
// "resolved but missing" — the instrumentation defect the prior fix shipped.
|
|
564
|
-
if (root !== null) {
|
|
565
|
-
diagnostics.packageJsonExists = safeExistsSync(fileSystem, path.join(root, "package.json"));
|
|
566
|
-
diagnostics.nodeModulesExists = safeExistsSync(fileSystem, path.join(root, "node_modules"));
|
|
567
|
-
}
|
|
568
|
-
return { incomplete_install: diagnostics };
|
|
569
694
|
}
|
|
570
695
|
/** existsSync that never throws — any filesystem error reads as "absent". */
|
|
571
696
|
function safeExistsSync(fileSystem, target) {
|
|
@@ -31,10 +31,13 @@
|
|
|
31
31
|
*/
|
|
32
32
|
/**
|
|
33
33
|
* The loader-error `code`s recovery classifies. The first two mean "a module
|
|
34
|
-
* could not be resolved"; `ENOENT` is the
|
|
35
|
-
* present at
|
|
34
|
+
* could not be resolved"; `ENOENT` is the loader/resolver dialect where a module
|
|
35
|
+
* was present at one step and gone at the next (HQ-CLI-1M esm-load open, HQ-CLI-20
|
|
36
|
+
* cjs-load open, HQ-CLI-1S resolve-time lstat); `EVAL_THROW` is the synthetic code
|
|
37
|
+
* for a module-EVALUATION throw that carries NO real `code` (HQ-CLI-1T) — see
|
|
38
|
+
* {@link ModuleErrorDialect}.
|
|
36
39
|
*/
|
|
37
|
-
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT";
|
|
40
|
+
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
|
|
38
41
|
/**
|
|
39
42
|
* The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
|
|
40
43
|
* @sentry/node import-in-the-middle hook does not change the shapes):
|
|
@@ -54,10 +57,27 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
|
|
|
54
57
|
* - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
|
|
55
58
|
* and gone at READ, so getSourceSync/openSync raises ENOENT
|
|
56
59
|
* (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
|
|
60
|
+
* - `cjs-enoent`: CJS loader ENOENT (HQ-CLI-20) — the CJS twin of esm-enoent: a
|
|
61
|
+
* require() whose target was present at RESOLVE (its filename
|
|
62
|
+
* held in Module._pathCache) and gone at READ, so the CJS
|
|
63
|
+
* loader's source read (defaultLoadImpl/loadSource, or the older
|
|
64
|
+
* Module._extensions..js handler) raises ENOENT with syscall
|
|
65
|
+
* 'open' and a cjs/loader frame but NO esm/load frame. `err.path`
|
|
66
|
+
* is the vanished file.
|
|
67
|
+
* - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
|
|
68
|
+
* stat'ed OK, then vanished before realpathSync's lstat, so the
|
|
69
|
+
* resolver (finalizeResolution / Module._findPath→toRealPath)
|
|
70
|
+
* raises ENOENT with syscall `lstat`. `err.path` is the vanished
|
|
71
|
+
* component (which may be a package/scope directory).
|
|
72
|
+
* - `eval-throw`: a module-EVALUATION throw (HQ-CLI-1T) — a dependency file was
|
|
73
|
+
* found but still half-written, so require() returned an empty
|
|
74
|
+
* module and its top-level code threw a code-LESS TypeError /
|
|
75
|
+
* ReferenceError / SyntaxError under Module._compile /
|
|
76
|
+
* esm/module_job. The specifier is the bounded throw-site path.
|
|
57
77
|
* - `unknown`: a module-not-found whose message did not parse; recovery
|
|
58
78
|
* still waits on the lock / retired-dir / quiet signals.
|
|
59
79
|
*/
|
|
60
|
-
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
|
|
80
|
+
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "cjs-enoent" | "resolve-enoent" | "eval-throw" | "unknown";
|
|
61
81
|
/**
|
|
62
82
|
* The missing thing, re-resolvable by the readiness probe:
|
|
63
83
|
* - `path`: an absolute filesystem path (a `.js` file, or a package dir).
|
|
@@ -66,6 +86,12 @@ export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-
|
|
|
66
86
|
* Node's CJS resolver does from directory `from` —
|
|
67
87
|
* `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
|
|
68
88
|
* and directory forms.
|
|
89
|
+
* - `exists`: an absolute path whose bare EXISTENCE is the readiness signal
|
|
90
|
+
* (HQ-CLI-1S) — the resolver lstat'ed this component, which may be
|
|
91
|
+
* a package/scope DIRECTORY whose contents precede a `package.json`,
|
|
92
|
+
* so the `path` kind's directory rule could stay false forever.
|
|
93
|
+
* Readiness still rides the lock / manifest-health / retired-sibling
|
|
94
|
+
* / quiet guards, exactly as for the loader-race dialects.
|
|
69
95
|
* - `unknown`: the message did not parse; treated as "present" by the probe so
|
|
70
96
|
* readiness turns only on the lock / retired-dir / quiet signals.
|
|
71
97
|
*/
|
|
@@ -80,6 +106,9 @@ export type ModuleErrorTarget = {
|
|
|
80
106
|
kind: "relative";
|
|
81
107
|
specifier: string;
|
|
82
108
|
from: string;
|
|
109
|
+
} | {
|
|
110
|
+
kind: "exists";
|
|
111
|
+
path: string;
|
|
83
112
|
} | {
|
|
84
113
|
kind: "unknown";
|
|
85
114
|
};
|
|
@@ -91,7 +120,10 @@ export interface ClassifiedModuleError {
|
|
|
91
120
|
/** The importer the message named (from-clause or requireStack[0]), or "". */
|
|
92
121
|
importer: string;
|
|
93
122
|
target: ModuleErrorTarget;
|
|
123
|
+
/** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else absent. */
|
|
124
|
+
errorName?: string;
|
|
94
125
|
}
|
|
126
|
+
export declare function hasCjsLoaderSourceReadFrame(stack: string): boolean;
|
|
95
127
|
/**
|
|
96
128
|
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
97
129
|
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
@@ -109,6 +141,39 @@ export declare function packageNameOf(specifier: string): string;
|
|
|
109
141
|
* `null` so it is rethrown to the existing boundary unchanged.
|
|
110
142
|
*/
|
|
111
143
|
export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
|
|
144
|
+
/** The structural fingerprint of a module-EVALUATION throw (HQ-CLI-1T). */
|
|
145
|
+
export interface EvalThrowShape {
|
|
146
|
+
/** The thrown error's `name`, in {@link EVAL_THROW_NAMES}. */
|
|
147
|
+
errorName: string;
|
|
148
|
+
/** The absolute throw-site path (first path token in the stack). */
|
|
149
|
+
throwSite: string;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
|
|
153
|
+
* boundary gate: an error with NO `code`, a `name` in the closed set
|
|
154
|
+
* {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
|
|
155
|
+
* frame, and a resolvable throw-site path. This is the shape a half-written
|
|
156
|
+
* dependency file produces — require() read an empty module and returned `{}`, so
|
|
157
|
+
* calling one of its exports throws a code-less TypeError while the module's own
|
|
158
|
+
* top-level code runs. No message text is ever parsed. The caller applies its own
|
|
159
|
+
* packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
|
|
160
|
+
* capture context), so this stays pure and reusable. Returns null for anything
|
|
161
|
+
* that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
|
|
162
|
+
* RangeError name, a throw with no loader frame, or no absolute throw site.
|
|
163
|
+
*/
|
|
164
|
+
export declare function evalThrowShape(err: unknown): EvalThrowShape | null;
|
|
165
|
+
/**
|
|
166
|
+
* Classify a module-EVALUATION throw as a torn-install recovery candidate
|
|
167
|
+
* (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
|
|
168
|
+
* `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
|
|
169
|
+
* `assets/`, outside the root, or with an unresolved root is NOT classified, so a
|
|
170
|
+
* genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
|
|
171
|
+
* dialects the recovery seam gates this on a live writer signal before waiting.
|
|
172
|
+
* `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
|
|
173
|
+
* fallback). The target is `unknown` — an eval-throw carries no re-resolvable
|
|
174
|
+
* missing file, so recovery keys entirely on the writer signal + quiet window.
|
|
175
|
+
*/
|
|
176
|
+
export declare function classifyEvalThrow(err: unknown, packageRoot: string | null): ClassifiedModuleError | null;
|
|
112
177
|
/** The filesystem surface the probe and wait use, injectable for hermetic tests. */
|
|
113
178
|
export interface InstallTreeFs {
|
|
114
179
|
existsSync(p: string): boolean;
|
|
@@ -133,6 +198,8 @@ export interface InstallTreeFs {
|
|
|
133
198
|
* or a directory whose package.json `main` (or that main's index)
|
|
134
199
|
* or own `index.*` is a real file. A bare or partially-extracted
|
|
135
200
|
* directory is NOT loadable and reads as not-present.
|
|
201
|
+
* - `exists`: the path exists at all (any file type) — presence only, because
|
|
202
|
+
* the vanished component may be a package/scope directory.
|
|
136
203
|
* - `unknown`: true (readiness turns on the other signals).
|
|
137
204
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
138
205
|
*/
|
|
@@ -221,6 +288,27 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
221
288
|
* lockWaitMs`), so the caller can never hang.
|
|
222
289
|
*/
|
|
223
290
|
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
291
|
+
export interface InstallRewriteSignalArgs {
|
|
292
|
+
/** The running install's package dir (from resolveRunningInstall), or null. */
|
|
293
|
+
packageRoot: string | null;
|
|
294
|
+
/** The shared update-lock path ($HOME/.hq/locks/cli-update.lock). */
|
|
295
|
+
lockPath: string;
|
|
296
|
+
now?: () => number;
|
|
297
|
+
fs?: InstallTreeFs;
|
|
298
|
+
isPidAlive?: (pid: number) => boolean;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Whether a global reinstall is PROVABLY rewriting the install tree right now —
|
|
302
|
+
* the positive writer signal the eval-throw gate requires before it will pay a
|
|
303
|
+
* settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
|
|
304
|
+
* manifest is absent or name-mismatched (mid-extraction), or (iii) a
|
|
305
|
+
* `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
|
|
306
|
+
* benign filesystem error that merely prevents READING the manifest or LISTING the
|
|
307
|
+
* parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
|
|
308
|
+
* defect on an otherwise-usable install never pays a spurious 90s wait. Every
|
|
309
|
+
* dependency is injectable so the recovery seam stays hermetic.
|
|
310
|
+
*/
|
|
311
|
+
export declare function installRewriteInProgress(args: InstallRewriteSignalArgs): boolean;
|
|
224
312
|
/** The recovery OUTCOME, which — not the dialect — decides capture. */
|
|
225
313
|
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
|
|
226
314
|
export type InstallTreeTornDiagnostics = {
|
|
@@ -241,6 +329,8 @@ export type InstallTreeTornDiagnostics = {
|
|
|
241
329
|
/** The bounded `tool` of the last fresh foreign lock seen, or "". */
|
|
242
330
|
lockTool: string;
|
|
243
331
|
node: string;
|
|
332
|
+
/** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else "". */
|
|
333
|
+
errorName: string;
|
|
244
334
|
};
|
|
245
335
|
export interface InstallTreeTornInit {
|
|
246
336
|
/** The original loader error, carried unchanged as `cause`. */
|
|
@@ -41,6 +41,8 @@ const IMPORTER_BYTES = 256;
|
|
|
41
41
|
const PACKAGE_ROOT_BYTES = 256;
|
|
42
42
|
/** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
|
|
43
43
|
const LOCK_TOOL_BYTES = 64;
|
|
44
|
+
/** The eval-throw error name is a short closed-set token; the cap is only a belt. */
|
|
45
|
+
const ERRORNAME_BYTES = 64;
|
|
44
46
|
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
45
47
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
46
48
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
@@ -54,6 +56,141 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
|
|
|
54
56
|
* the settle wait. `load\b` also excludes the sibling `esm/loader` module.
|
|
55
57
|
*/
|
|
56
58
|
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
59
|
+
/**
|
|
60
|
+
* The Node module RESOLVER frame — `finalizeResolution` in
|
|
61
|
+
* `node:internal/modules/esm/resolve` (the observed HQ-CLI-1S route, where
|
|
62
|
+
* realpathSync lstat's a component that vanished after the earlier stat), or the
|
|
63
|
+
* CJS `Module._findPath → toRealPath` route in `node:internal/modules/cjs/loader`
|
|
64
|
+
* (the same stat-then-lstat TOCTOU, accepted for symmetry). Requiring a resolver
|
|
65
|
+
* frame — alongside `syscall === 'lstat'` — is what proves the ENOENT came from
|
|
66
|
+
* the resolver's realpath step, so an ordinary `fs.lstatSync` ENOENT written by
|
|
67
|
+
* hq's own code (no resolver frame) is NOT misclassified as a torn install.
|
|
68
|
+
*/
|
|
69
|
+
const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
|
|
70
|
+
/** The closed set of error `name`s the eval-throw dialect accepts (HQ-CLI-1T). */
|
|
71
|
+
const EVAL_THROW_NAMES = new Set(["TypeError", "ReferenceError", "SyntaxError"]);
|
|
72
|
+
/**
|
|
73
|
+
* A CJS module-EVALUATION frame — `Module._compile` / `wrapSafe` in
|
|
74
|
+
* `node:internal/modules/cjs/loader`, where a required module's top-level code
|
|
75
|
+
* runs (and where a SyntaxError is raised at compile). NOT the resolve frame
|
|
76
|
+
* `Module._resolveFilename`, so a resolution failure is never read as an eval.
|
|
77
|
+
*/
|
|
78
|
+
const CJS_EVAL_FRAME = /\bat (?:Module\._compile|wrapSafe) \(node:internal[\\/]modules[\\/]cjs[\\/]loader/;
|
|
79
|
+
/** An ESM module-EVALUATION frame — `ModuleJob` run/evaluate in esm/module_job. */
|
|
80
|
+
const ESM_EVAL_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]module_job\b/;
|
|
81
|
+
/** Whether `stack` carries a CJS or ESM module-EVALUATION loader frame. */
|
|
82
|
+
function hasModuleEvalFrame(stack) {
|
|
83
|
+
return CJS_EVAL_FRAME.test(stack) || ESM_EVAL_FRAME.test(stack);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The Node CJS loader's SOURCE-READ position (HQ-CLI-20) — the CJS twin of
|
|
87
|
+
* {@link ESM_LOADER_FRAME}. A CJS require whose target was present at RESOLVE (its
|
|
88
|
+
* filename cached in Module._pathCache) and gone at READ fails inside the loader's
|
|
89
|
+
* source read: `readFileSync (node:fs)` called by `defaultLoadImpl`/`loadSource`
|
|
90
|
+
* (Node ≥22) or, on older Node, the `Module._extensions..js` extension handler —
|
|
91
|
+
* all frames in `node:internal/modules/cjs/loader`, and (unlike HQ-CLI-1M) with NO
|
|
92
|
+
* `esm/load` frame.
|
|
93
|
+
*
|
|
94
|
+
* The discriminator is POSITIONAL: the innermost `node:fs` read frame's
|
|
95
|
+
* IMMEDIATELY-following (caller) frame must be a cjs/loader frame that is NOT a
|
|
96
|
+
* module-EVALUATION frame (Module._compile / wrapSafe). That is what proves the
|
|
97
|
+
* LOADER was reading the module's source. An ordinary `fs.readFileSync`/`openSync`
|
|
98
|
+
* ENOENT that merely ESCAPES a third-party module's own EVALUATION has the
|
|
99
|
+
* third-party FILE directly beneath `node:fs`, not a cjs/loader frame, so it stays
|
|
100
|
+
* captured (both shapes reproduced frame-for-frame on Node v22.23.1). The
|
|
101
|
+
* positional rule is the PRIMARY discriminator; the source-read helper NAMES are
|
|
102
|
+
* an accepted closed set, not the sole requirement, so a renamed helper still
|
|
103
|
+
* classifies.
|
|
104
|
+
*
|
|
105
|
+
* The whole-stack presence of Module._compile is deliberately NOT a rejection
|
|
106
|
+
* signal: under a raised Error.stackTraceLimit the torn shape ALSO carries the
|
|
107
|
+
* requiring module's own Module._compile frame — always at depth 11, past the
|
|
108
|
+
* default limit of 10 — so only the frame immediately beneath the node:fs read is
|
|
109
|
+
* consulted. Exported so incomplete-install-error.ts (which already imports from
|
|
110
|
+
* this module) shares the exact gate rather than duplicating the positional logic,
|
|
111
|
+
* mirroring how {@link evalThrowShape} and {@link packageNameOf} are shared.
|
|
112
|
+
*/
|
|
113
|
+
const NODE_FS_READ_FRAME = /^at (?:[\w$.]+\.)?(?:readFileSync|openSync) \(node:fs\b/;
|
|
114
|
+
const CJS_LOADER_FRAME = /node:internal[\\/]modules[\\/]cjs[\\/]loader\b/;
|
|
115
|
+
export function hasCjsLoaderSourceReadFrame(stack) {
|
|
116
|
+
const frames = stack
|
|
117
|
+
.split("\n")
|
|
118
|
+
.map((line) => line.trim())
|
|
119
|
+
.filter((line) => line.startsWith("at "));
|
|
120
|
+
for (let i = 0; i < frames.length; i++) {
|
|
121
|
+
if (!NODE_FS_READ_FRAME.test(frames[i]))
|
|
122
|
+
continue;
|
|
123
|
+
const caller = frames[i + 1];
|
|
124
|
+
if (caller === undefined)
|
|
125
|
+
return false;
|
|
126
|
+
return CJS_LOADER_FRAME.test(caller) && !CJS_EVAL_FRAME.test(caller);
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A stack LOCATION token — the absolute filesystem path in either an
|
|
132
|
+
* `at <fn> (<abs>:L:C)` frame or a bare `<abs>:L` header line (the source
|
|
133
|
+
* location a SyntaxError prepends). Anchored at line-start or an opening paren so
|
|
134
|
+
* a `node:internal/…` internal frame (never an absolute FS path) is skipped, and
|
|
135
|
+
* a Windows drive/UNC path is matched by shape so it classifies on any host.
|
|
136
|
+
*/
|
|
137
|
+
const STACK_LOCATION = /(?:^|\()((?:\/|[A-Za-z]:[\\/]|\\\\)[^():\n]*?):\d+(?::\d+)?(?:\)|$)/;
|
|
138
|
+
/**
|
|
139
|
+
* A `file:` URL location in a stack frame — the form Node uses for an ESM module
|
|
140
|
+
* EVALUATION frame (`at file:///abs/mod.mjs:2:1`, or the same wrapped in parens).
|
|
141
|
+
* The whole non-space token is captured; the caller strips the trailing paren and
|
|
142
|
+
* `:line[:col]` and decodes it with fileURLToPath.
|
|
143
|
+
*/
|
|
144
|
+
function fileUrlFromFrame(line) {
|
|
145
|
+
const token = line.match(/file:\/\/\/\S+/);
|
|
146
|
+
if (!token)
|
|
147
|
+
return null;
|
|
148
|
+
const url = token[0].replace(/\)+$/, "").replace(/:\d+(?::\d+)?$/, "");
|
|
149
|
+
try {
|
|
150
|
+
return fileURLToPath(url);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null; // not a decodable file URL
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The eval-throw THROW SITE — the FIRST absolute filesystem path token in the
|
|
158
|
+
* stack, scanning top-down: for an ordinary error that is the throwing frame (a
|
|
159
|
+
* raw path for a CJS module, or a `file:` URL for an ESM one); for a SyntaxError
|
|
160
|
+
* it is the `<abs>:L` header line the stack carries before the `Error:` line.
|
|
161
|
+
* Returns null when no absolute path token is present.
|
|
162
|
+
*/
|
|
163
|
+
function evalThrowSite(stack) {
|
|
164
|
+
for (const rawLine of stack.split("\n")) {
|
|
165
|
+
const line = rawLine.trim();
|
|
166
|
+
const plain = STACK_LOCATION.exec(line);
|
|
167
|
+
if (plain)
|
|
168
|
+
return plain[1];
|
|
169
|
+
const fromUrl = fileUrlFromFrame(line);
|
|
170
|
+
if (fromUrl)
|
|
171
|
+
return fromUrl;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Lexical `<root>/node_modules/` containment for the eval-throw throw-site gate.
|
|
177
|
+
* Separator- and (Windows-shape-only) case-folded and anchored at a true
|
|
178
|
+
* directory boundary, so a sibling such as `<root>-old/node_modules/…` never
|
|
179
|
+
* matches. Mirrors incomplete-install-error.ts's isUnderNodeModules discipline;
|
|
180
|
+
* kept local because that module imports THIS one (a shared import would cycle).
|
|
181
|
+
*/
|
|
182
|
+
function looksWin32(p) {
|
|
183
|
+
return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
|
|
184
|
+
}
|
|
185
|
+
function foldForCompare(p) {
|
|
186
|
+
const folded = p.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
|
|
187
|
+
return looksWin32(p) ? folded.toLowerCase() : folded;
|
|
188
|
+
}
|
|
189
|
+
function isUnderNodeModules(candidate, root) {
|
|
190
|
+
if (!candidate || !root)
|
|
191
|
+
return false;
|
|
192
|
+
return foldForCompare(candidate).startsWith(`${foldForCompare(root)}/node_modules/`);
|
|
193
|
+
}
|
|
57
194
|
/**
|
|
58
195
|
* A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
|
|
59
196
|
* Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
|
|
@@ -122,6 +259,53 @@ export function classifyModuleNotFound(err) {
|
|
|
122
259
|
target: { kind: "path", path: record.path },
|
|
123
260
|
};
|
|
124
261
|
}
|
|
262
|
+
// cjs-loader source-read ENOENT (HQ-CLI-20): the CJS twin of esm-enoent — a
|
|
263
|
+
// require() whose target was present at RESOLVE (its filename held in
|
|
264
|
+
// Module._pathCache) and gone at READ, so the CJS loader's source read raises
|
|
265
|
+
// ENOENT with syscall 'open' and a cjs/loader frame but NO esm/load frame
|
|
266
|
+
// (which is why arm (0) missed it and classifyModuleNotFound returned null,
|
|
267
|
+
// filing the bare HQ-CLI-20 capture). Gated on the FULL conjunction — code
|
|
268
|
+
// ENOENT AND syscall 'open' AND a string path AND the POSITIONAL CJS
|
|
269
|
+
// source-read gate — so a third-party module's own top-level fs.readFileSync
|
|
270
|
+
// ENOENT (module EVALUATION, its own frame directly beneath node:fs) stays null
|
|
271
|
+
// and keeps its capture. Ordered AFTER the esm-enoent arm so HQ-CLI-1M keeps
|
|
272
|
+
// its byte-for-byte classification. Target 'path': the vanished file is a
|
|
273
|
+
// regular .js/.json file Node's CJS LOAD_AS_FILE step re-resolves, so the probe
|
|
274
|
+
// and settle wait reuse the same machinery as esm-enoent, unchanged.
|
|
275
|
+
if (record.syscall === "open" &&
|
|
276
|
+
typeof record.path === "string" &&
|
|
277
|
+
typeof record.stack === "string" &&
|
|
278
|
+
hasCjsLoaderSourceReadFrame(record.stack)) {
|
|
279
|
+
return {
|
|
280
|
+
code,
|
|
281
|
+
dialect: "cjs-enoent",
|
|
282
|
+
specifier: record.path,
|
|
283
|
+
importer: "",
|
|
284
|
+
target: { kind: "path", path: record.path },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// resolve-time ENOENT (HQ-CLI-1S): Node's resolver stat'ed a path OK, then
|
|
288
|
+
// realpathSync lstat'ed a component that a concurrent reinstall renamed away
|
|
289
|
+
// between the two, throwing ENOENT with syscall `lstat` from a RESOLVER frame
|
|
290
|
+
// (finalizeResolution / Module._findPath→toRealPath). Gated on the FULL
|
|
291
|
+
// conjunction so an ordinary fs.lstatSync ENOENT from hq's own code (no
|
|
292
|
+
// resolver frame) and a rename/unlink ENOENT (syscall not `lstat`) both stay
|
|
293
|
+
// null. The vanished component may be a package/scope DIRECTORY whose bare
|
|
294
|
+
// existence precedes its `package.json`, so the target is `exists` (presence
|
|
295
|
+
// only); readiness still rides the lock / manifest-health / retired-sibling /
|
|
296
|
+
// quiet guards. Structurally a loader race like esm-enoent — no writer gate.
|
|
297
|
+
if (record.syscall === "lstat" &&
|
|
298
|
+
typeof record.path === "string" &&
|
|
299
|
+
typeof record.stack === "string" &&
|
|
300
|
+
RESOLVER_FRAME.test(record.stack)) {
|
|
301
|
+
return {
|
|
302
|
+
code,
|
|
303
|
+
dialect: "resolve-enoent",
|
|
304
|
+
specifier: record.path,
|
|
305
|
+
importer: "",
|
|
306
|
+
target: { kind: "exists", path: record.path },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
125
309
|
return null;
|
|
126
310
|
}
|
|
127
311
|
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
|
@@ -206,6 +390,61 @@ export function classifyModuleNotFound(err) {
|
|
|
206
390
|
// the lock / retired-dir / quiet signals only.
|
|
207
391
|
return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
|
|
208
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
|
|
395
|
+
* boundary gate: an error with NO `code`, a `name` in the closed set
|
|
396
|
+
* {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
|
|
397
|
+
* frame, and a resolvable throw-site path. This is the shape a half-written
|
|
398
|
+
* dependency file produces — require() read an empty module and returned `{}`, so
|
|
399
|
+
* calling one of its exports throws a code-less TypeError while the module's own
|
|
400
|
+
* top-level code runs. No message text is ever parsed. The caller applies its own
|
|
401
|
+
* packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
|
|
402
|
+
* capture context), so this stays pure and reusable. Returns null for anything
|
|
403
|
+
* that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
|
|
404
|
+
* RangeError name, a throw with no loader frame, or no absolute throw site.
|
|
405
|
+
*/
|
|
406
|
+
export function evalThrowShape(err) {
|
|
407
|
+
if (err === null || typeof err !== "object")
|
|
408
|
+
return null;
|
|
409
|
+
const record = err;
|
|
410
|
+
if (record.code !== undefined)
|
|
411
|
+
return null;
|
|
412
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
413
|
+
if (!EVAL_THROW_NAMES.has(name))
|
|
414
|
+
return null;
|
|
415
|
+
if (typeof record.stack !== "string" || !hasModuleEvalFrame(record.stack))
|
|
416
|
+
return null;
|
|
417
|
+
const throwSite = evalThrowSite(record.stack);
|
|
418
|
+
if (throwSite === null)
|
|
419
|
+
return null;
|
|
420
|
+
return { errorName: name, throwSite };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Classify a module-EVALUATION throw as a torn-install recovery candidate
|
|
424
|
+
* (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
|
|
425
|
+
* `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
|
|
426
|
+
* `assets/`, outside the root, or with an unresolved root is NOT classified, so a
|
|
427
|
+
* genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
|
|
428
|
+
* dialects the recovery seam gates this on a live writer signal before waiting.
|
|
429
|
+
* `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
|
|
430
|
+
* fallback). The target is `unknown` — an eval-throw carries no re-resolvable
|
|
431
|
+
* missing file, so recovery keys entirely on the writer signal + quiet window.
|
|
432
|
+
*/
|
|
433
|
+
export function classifyEvalThrow(err, packageRoot) {
|
|
434
|
+
const shape = evalThrowShape(err);
|
|
435
|
+
if (shape === null)
|
|
436
|
+
return null;
|
|
437
|
+
if (!packageRoot || !isUnderNodeModules(shape.throwSite, packageRoot))
|
|
438
|
+
return null;
|
|
439
|
+
return {
|
|
440
|
+
code: "EVAL_THROW",
|
|
441
|
+
dialect: "eval-throw",
|
|
442
|
+
specifier: shape.throwSite,
|
|
443
|
+
importer: "",
|
|
444
|
+
target: { kind: "unknown" },
|
|
445
|
+
errorName: shape.errorName,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
209
448
|
const nodeInstallTreeFs = {
|
|
210
449
|
existsSync: (p) => nodeFs.existsSync(p),
|
|
211
450
|
statSync: (p) => nodeFs.statSync(p),
|
|
@@ -289,6 +528,8 @@ function relativeTargetResolvable(base, fs) {
|
|
|
289
528
|
* or a directory whose package.json `main` (or that main's index)
|
|
290
529
|
* or own `index.*` is a real file. A bare or partially-extracted
|
|
291
530
|
* directory is NOT loadable and reads as not-present.
|
|
531
|
+
* - `exists`: the path exists at all (any file type) — presence only, because
|
|
532
|
+
* the vanished component may be a package/scope directory.
|
|
292
533
|
* - `unknown`: true (readiness turns on the other signals).
|
|
293
534
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
294
535
|
*/
|
|
@@ -296,6 +537,12 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
296
537
|
try {
|
|
297
538
|
if (target.kind === "unknown")
|
|
298
539
|
return true;
|
|
540
|
+
// `exists` (resolve-enoent, HQ-CLI-1S): the lstat'ed component may be a
|
|
541
|
+
// package/scope DIRECTORY whose bare existence precedes its `package.json`, so
|
|
542
|
+
// presence alone is the signal here — the surrounding manifest-health /
|
|
543
|
+
// retired-sibling / quiet guards keep a premature re-exec bounded.
|
|
544
|
+
if (target.kind === "exists")
|
|
545
|
+
return fs.existsSync(target.path);
|
|
299
546
|
if (target.kind === "path") {
|
|
300
547
|
if (!fs.existsSync(target.path))
|
|
301
548
|
return false;
|
|
@@ -509,6 +756,57 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
509
756
|
await sleep(pollMs);
|
|
510
757
|
}
|
|
511
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Whether packageRoot's own manifest is ABSENT (ENOENT) or name-mismatched — a
|
|
761
|
+
* mid-extraction rewrite signal (npm has renamed the dir aside and is re-writing
|
|
762
|
+
* package.json). A read error OTHER than ENOENT (EACCES/EPERM) is a benign
|
|
763
|
+
* permission fault, not a rewrite, so it returns false rather than a false signal.
|
|
764
|
+
*/
|
|
765
|
+
function manifestRewriteSignal(packageRoot, fs) {
|
|
766
|
+
let raw;
|
|
767
|
+
try {
|
|
768
|
+
raw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
return err?.code === "ENOENT";
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
return JSON.parse(raw).name !== CLI_NAME;
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
return true; // present but unparseable → mid-write
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Whether a global reinstall is PROVABLY rewriting the install tree right now —
|
|
782
|
+
* the positive writer signal the eval-throw gate requires before it will pay a
|
|
783
|
+
* settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
|
|
784
|
+
* manifest is absent or name-mismatched (mid-extraction), or (iii) a
|
|
785
|
+
* `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
|
|
786
|
+
* benign filesystem error that merely prevents READING the manifest or LISTING the
|
|
787
|
+
* parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
|
|
788
|
+
* defect on an otherwise-usable install never pays a spurious 90s wait. Every
|
|
789
|
+
* dependency is injectable so the recovery seam stays hermetic.
|
|
790
|
+
*/
|
|
791
|
+
export function installRewriteInProgress(args) {
|
|
792
|
+
const now = (args.now ?? Date.now)();
|
|
793
|
+
const fs = args.fs ?? nodeInstallTreeFs;
|
|
794
|
+
const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
|
|
795
|
+
// #570 renamed freshForeignLockHeld → freshForeignLock, which returns the lock
|
|
796
|
+
// info (or null) instead of a boolean; a fresh foreign lock is held iff non-null.
|
|
797
|
+
if (freshForeignLock(args.lockPath, now, isPidAlive, fs) !== null)
|
|
798
|
+
return true;
|
|
799
|
+
if (!args.packageRoot)
|
|
800
|
+
return false;
|
|
801
|
+
if (manifestRewriteSignal(args.packageRoot, fs))
|
|
802
|
+
return true;
|
|
803
|
+
try {
|
|
804
|
+
return retiredSiblingPresent(args.packageRoot, fs);
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return false; // parent not listable — benign, not a rewrite signal
|
|
808
|
+
}
|
|
809
|
+
}
|
|
512
810
|
/** The fixed, input-free carrier message (grouping cardinality stays bounded). */
|
|
513
811
|
const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
|
|
514
812
|
/**
|
|
@@ -539,6 +837,7 @@ export class InstallTreeTornError extends Error {
|
|
|
539
837
|
lockedMs: init.lockedMs ?? 0,
|
|
540
838
|
lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
|
|
541
839
|
node: process.version,
|
|
840
|
+
errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
|
|
542
841
|
};
|
|
543
842
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
544
843
|
}
|