@indigoai-us/hq-cli 5.109.12 → 5.109.14
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 +38 -0
- package/dist/main.js +12 -9
- package/dist/sentry.js +4 -2
- package/dist/utils/incomplete-install-error.d.ts +6 -3
- package/dist/utils/incomplete-install-error.js +58 -13
- package/dist/utils/install-tree-torn.d.ts +13 -4
- package/dist/utils/install-tree-torn.js +70 -0
- package/dist/utils/sentry-fingerprint.d.ts +6 -1
- package/dist/utils/sentry-fingerprint.js +50 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.14] — 2026-09-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Repeated "install tree was being rewritten" reports from a background reinstall
|
|
10
|
+
now collapse into one stable issue per outcome instead of spawning a fresh crash
|
|
11
|
+
report on every occurrence (Sentry HQ-CLI-21, HQ-CLI-22, HQ-CLI-23). hq already
|
|
12
|
+
groups these by their recovery outcome, but the grouping key led with a
|
|
13
|
+
placeholder that also told Sentry to fold in its own default grouping — and for
|
|
14
|
+
this error that default is the underlying loader failure, whose details differ
|
|
15
|
+
every time (which dependency file the reinstall happened to be rewriting, under
|
|
16
|
+
each machine's install path). So each occurrence minted a new issue and the
|
|
17
|
+
family never settled down. The key is now fully explicit and no longer inherits
|
|
18
|
+
that default, so future occurrences with the same outcome share one group. The
|
|
19
|
+
existing issues HQ-CLI-21/22/23 stay open as they are: a grouping change only
|
|
20
|
+
affects events reported after it ships.
|
|
21
|
+
|
|
22
|
+
## [5.109.13] — 2026-09-13
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- A background reinstall no longer turns into a crash report in the CommonJS twin
|
|
27
|
+
of the shape 5.109.12 fixed for ES modules (HQ-CLI-20). When another program
|
|
28
|
+
reinstalls hq globally while a command is starting, it rewrites hq's files one
|
|
29
|
+
by one over a few seconds. If a bundled CommonJS dependency (here `ajv`, reached
|
|
30
|
+
while `hq core timeout-guard` registers its commands) had just resolved one of
|
|
31
|
+
its own files and that file vanished before Node read it, the low-level `ENOENT …
|
|
32
|
+
open` came from inside Node's CommonJS module loader — a shape the recovery only
|
|
33
|
+
recognized for ES modules, so it was still filed as a crash. It is now recognized
|
|
34
|
+
as "your install was mid-update, not an hq bug": the command waits for the
|
|
35
|
+
reinstall to settle and re-runs once on the healed tree, and the same noise is
|
|
36
|
+
dropped on the routes that bypass the top-level handler. The gate is deliberately
|
|
37
|
+
narrow — it fires only when Node's own loader was reading a module's source (the
|
|
38
|
+
frame directly beneath the file read is a `cjs/loader` frame, never the
|
|
39
|
+
dependency's own code) — so a third-party package whose own startup code reads a
|
|
40
|
+
missing file, or a fault in hq's own shipped files, still reports exactly once
|
|
41
|
+
(Sentry HQ-CLI-20).
|
|
42
|
+
|
|
5
43
|
## [5.109.12] — 2026-09-12
|
|
6
44
|
|
|
7
45
|
### 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
|
|
@@ -50,6 +50,8 @@ export type IncompleteInstallEsmDiagnostics = {
|
|
|
50
50
|
esmLoaderFrame: boolean;
|
|
51
51
|
/** The stack carried a module-resolver frame — the HQ-CLI-1S resolve-time route. */
|
|
52
52
|
resolverFrame: boolean;
|
|
53
|
+
/** The stack carried a CJS loader (source-read) frame — the HQ-CLI-20 route. */
|
|
54
|
+
cjsLoaderFrame: boolean;
|
|
53
55
|
code: string;
|
|
54
56
|
};
|
|
55
57
|
/**
|
|
@@ -119,9 +121,10 @@ export type IncompleteInstallDiagnostics = Partial<IncompleteInstallEsmDiagnosti
|
|
|
119
121
|
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
120
122
|
* packageRootResolved, resolver, requirerScope };
|
|
121
123
|
* - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
|
|
122
|
-
* esm-load open, HQ-CLI-1S resolve-time lstat
|
|
123
|
-
* value nor node_system_error carried a `path` →
|
|
124
|
-
* packageJsonExists, nodeModulesExists, esmLoaderFrame,
|
|
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 };
|
|
125
128
|
* - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
|
|
126
129
|
* { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
|
|
127
130
|
* resolver }.
|
|
@@ -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 { evalThrowShape, 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
|
|
@@ -348,6 +359,25 @@ function isResolveEnoent(err) {
|
|
|
348
359
|
record.syscall === "lstat" &&
|
|
349
360
|
hasResolverFrame(record.stack));
|
|
350
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
|
+
}
|
|
351
381
|
/**
|
|
352
382
|
* If `err` is an in-process incomplete-install module-load failure — the CJS
|
|
353
383
|
* relative-sibling shape (HQ-CLI-1N), the ESM vanished-at-read shape (HQ-CLI-1M),
|
|
@@ -421,6 +451,17 @@ export function incompleteInstallMessage(err, resolvePackageRoot = safePackageRo
|
|
|
421
451
|
hasResolverFrame(record.stack)) {
|
|
422
452
|
return isUnderNodeModules(record.path, root) ? INCOMPLETE_INSTALL_REMEDY : null;
|
|
423
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
|
+
}
|
|
424
465
|
return null;
|
|
425
466
|
}
|
|
426
467
|
/** A readFileSync surface, defaulting to the real fs when the caller injects none. */
|
|
@@ -550,9 +591,10 @@ function relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem) {
|
|
|
550
591
|
* (HQ-CLI-1N) → { code, relativeSpecifier:true, packageRoot,
|
|
551
592
|
* packageRootResolved, resolver, requirerScope };
|
|
552
593
|
* - a loader/resolver ENOENT whose path did not survive delivery (HQ-CLI-1M
|
|
553
|
-
* esm-load open, HQ-CLI-1S resolve-time lstat
|
|
554
|
-
* value nor node_system_error carried a `path` →
|
|
555
|
-
* packageJsonExists, nodeModulesExists, esmLoaderFrame,
|
|
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 };
|
|
556
598
|
* - a module-EVALUATION throw that reached the boundary raw (HQ-CLI-1T) →
|
|
557
599
|
* { evalThrow, errorName, throwSitePackage, packageRoot, packageRootResolved,
|
|
558
600
|
* resolver }.
|
|
@@ -570,11 +612,12 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
570
612
|
const relative = relativeSpecifierCaptureContext(err, resolvePackageRoot, fileSystem);
|
|
571
613
|
if (relative)
|
|
572
614
|
return relative;
|
|
573
|
-
// (C) HQ-CLI-1M / HQ-CLI-1S — the path-less loader/resolver ENOENT
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
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)) {
|
|
578
621
|
if (incompleteInstallMessage(err, resolvePackageRoot, readFileFrom(fileSystem)) === null) {
|
|
579
622
|
const record = err;
|
|
580
623
|
const code = typeof record.code === "string" ? record.code : "";
|
|
@@ -593,9 +636,11 @@ export function incompleteInstallCaptureContext(err, resolvePackageRoot = safePa
|
|
|
593
636
|
packageRootResolved: root !== null,
|
|
594
637
|
resolver: resolution.source,
|
|
595
638
|
// Which route raised it — the esm-loader (source-read) frame for 1M, the
|
|
596
|
-
// resolver frame for 1S
|
|
639
|
+
// resolver frame for 1S, the CJS loader source-read frame for HQ-CLI-20;
|
|
640
|
+
// any are recorded from the stack.
|
|
597
641
|
esmLoaderFrame: hasEsmLoaderFrame(record.stack),
|
|
598
642
|
resolverFrame: hasResolverFrame(record.stack),
|
|
643
|
+
cjsLoaderFrame: hasCjsLoaderFrame(record.stack),
|
|
599
644
|
code: boundedDiagnosticValue(code, CODE_DIAGNOSTIC_BYTES),
|
|
600
645
|
};
|
|
601
646
|
// Report the existence booleans ONLY when a root was resolved. Reported for
|
|
@@ -32,9 +32,10 @@
|
|
|
32
32
|
/**
|
|
33
33
|
* The loader-error `code`s recovery classifies. The first two mean "a module
|
|
34
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-
|
|
36
|
-
* resolve-time lstat); `EVAL_THROW` is the synthetic code
|
|
37
|
-
* throw that carries NO real `code` (HQ-CLI-1T) — see
|
|
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}.
|
|
38
39
|
*/
|
|
39
40
|
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
|
|
40
41
|
/**
|
|
@@ -56,6 +57,13 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
|
|
|
56
57
|
* - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
|
|
57
58
|
* and gone at READ, so getSourceSync/openSync raises ENOENT
|
|
58
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.
|
|
59
67
|
* - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
|
|
60
68
|
* stat'ed OK, then vanished before realpathSync's lstat, so the
|
|
61
69
|
* resolver (finalizeResolution / Module._findPath→toRealPath)
|
|
@@ -69,7 +77,7 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
|
|
|
69
77
|
* - `unknown`: a module-not-found whose message did not parse; recovery
|
|
70
78
|
* still waits on the lock / retired-dir / quiet signals.
|
|
71
79
|
*/
|
|
72
|
-
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "resolve-enoent" | "eval-throw" | "unknown";
|
|
80
|
+
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "cjs-enoent" | "resolve-enoent" | "eval-throw" | "unknown";
|
|
73
81
|
/**
|
|
74
82
|
* The missing thing, re-resolvable by the readiness probe:
|
|
75
83
|
* - `path`: an absolute filesystem path (a `.js` file, or a package dir).
|
|
@@ -115,6 +123,7 @@ export interface ClassifiedModuleError {
|
|
|
115
123
|
/** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else absent. */
|
|
116
124
|
errorName?: string;
|
|
117
125
|
}
|
|
126
|
+
export declare function hasCjsLoaderSourceReadFrame(stack: string): boolean;
|
|
118
127
|
/**
|
|
119
128
|
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
120
129
|
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
@@ -82,6 +82,51 @@ const ESM_EVAL_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]module_job\b/;
|
|
|
82
82
|
function hasModuleEvalFrame(stack) {
|
|
83
83
|
return CJS_EVAL_FRAME.test(stack) || ESM_EVAL_FRAME.test(stack);
|
|
84
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
|
+
}
|
|
85
130
|
/**
|
|
86
131
|
* A stack LOCATION token — the absolute filesystem path in either an
|
|
87
132
|
* `at <fn> (<abs>:L:C)` frame or a bare `<abs>:L` header line (the source
|
|
@@ -214,6 +259,31 @@ export function classifyModuleNotFound(err) {
|
|
|
214
259
|
target: { kind: "path", path: record.path },
|
|
215
260
|
};
|
|
216
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
|
+
}
|
|
217
287
|
// resolve-time ENOENT (HQ-CLI-1S): Node's resolver stat'ed a path OK, then
|
|
218
288
|
// realpathSync lstat'ed a component that a concurrent reinstall renamed away
|
|
219
289
|
// between the two, throwing ENOENT with syscall `lstat` from a RESOLVER frame
|
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
* carries no discriminator from the closed allowlist (in which case the caller
|
|
4
4
|
* must leave the fingerprint unset so Sentry's default grouping applies).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* For the rpc/http carriers the shape is `[DEFAULT_GROUPING, errorName,
|
|
7
|
+
* discriminator]`:
|
|
7
8
|
* - a numeric `rpcCode` (only the gateway's IntegrationsCliError carries one)
|
|
8
9
|
* yields `rpc:<code>` for a known code, else `rpc:other`;
|
|
9
10
|
* - otherwise a numeric HTTP `status` yields `http:<status>` for a known
|
|
10
11
|
* status, else `http:other`.
|
|
12
|
+
* The InstallTreeTornError branch is the deliberate exception: it returns a
|
|
13
|
+
* fully explicit `["InstallTreeTornError", "outcome:<token>"]` with NO
|
|
14
|
+
* DEFAULT_GROUPING lead, because its chained loader cause makes the default
|
|
15
|
+
* component unbounded (see the module header — HQ-CLI-21/22/23).
|
|
11
16
|
*
|
|
12
17
|
* `rpcCode` is checked first so a gateway JSON-RPC fault groups by its protocol
|
|
13
18
|
* code; the two carriers are disjoint in practice (the JSON-RPC throw site sets
|
|
@@ -16,12 +16,33 @@
|
|
|
16
16
|
// bucket destroys the regression watermark for every lane keyed on the issue.
|
|
17
17
|
//
|
|
18
18
|
// The remedy is a client-side fingerprint that SPLITS by a bounded machine
|
|
19
|
-
// discriminator
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// default
|
|
19
|
+
// discriminator. Two shapes are used, and the difference between them is
|
|
20
|
+
// load-bearing — it is the lesson HQ-CLI-21/22/23 taught:
|
|
21
|
+
//
|
|
22
|
+
// - MOST branches (qmd, rpc, http) LEAD the array with the `{{ default }}`
|
|
23
|
+
// placeholder, then append the error's own name plus one discriminator token
|
|
24
|
+
// from a finite set. Sentry expands `{{ default }}` into the event's default
|
|
25
|
+
// grouping component and SALTS the appended tokens with it, so the key is only
|
|
26
|
+
// as bounded as that default component. For those carriers that is fine: they
|
|
27
|
+
// are thrown from hq-cli's own code with no chained third-party cause, so
|
|
28
|
+
// their default component is itself bounded.
|
|
29
|
+
//
|
|
30
|
+
// - The InstallTreeTornError branch must NOT lead with the placeholder. Its
|
|
31
|
+
// carrier wraps a CHAINED loader failure whose stack differs on every
|
|
32
|
+
// occurrence — whichever dependency file a concurrent global reinstall was
|
|
33
|
+
// mid-rewrite, under each machine's absolute install path — so its default
|
|
34
|
+
// component is UNBOUNDED. Leading with `{{ default }}` salted the bounded
|
|
35
|
+
// `outcome:` key with that unbounded component and minted a brand-new Sentry
|
|
36
|
+
// issue per occurrence: three events carrying the byte-identical array
|
|
37
|
+
// ["{{ default }}", "InstallTreeTornError", "outcome:unsettled"] still landed
|
|
38
|
+
// in three different groups (hashes a4289a5e530e166ebdedea9d34dcad0a,
|
|
39
|
+
// b563c1b891bd975bdc605efc86034b44, b4ee254473ea249758617de1a98848fa —
|
|
40
|
+
// HQ-CLI-21/22/23). So that branch returns a FULLY EXPLICIT key with no
|
|
41
|
+
// default lead: ["InstallTreeTornError", "outcome:<token>"].
|
|
42
|
+
//
|
|
43
|
+
// Genuinely different failure classes therefore form distinct, PREDICTABLE
|
|
44
|
+
// groups, and everything with no bounded discriminator is left to default
|
|
45
|
+
// grouping untouched.
|
|
25
46
|
//
|
|
26
47
|
// Two hard rules, mirroring HQ-CLI-S:
|
|
27
48
|
// 1. CLOSED ALLOWLIST. The discriminator is one of a finite set of known
|
|
@@ -169,11 +190,16 @@ function qmdDispositionToken(status, signal) {
|
|
|
169
190
|
* carries no discriminator from the closed allowlist (in which case the caller
|
|
170
191
|
* must leave the fingerprint unset so Sentry's default grouping applies).
|
|
171
192
|
*
|
|
172
|
-
*
|
|
193
|
+
* For the rpc/http carriers the shape is `[DEFAULT_GROUPING, errorName,
|
|
194
|
+
* discriminator]`:
|
|
173
195
|
* - a numeric `rpcCode` (only the gateway's IntegrationsCliError carries one)
|
|
174
196
|
* yields `rpc:<code>` for a known code, else `rpc:other`;
|
|
175
197
|
* - otherwise a numeric HTTP `status` yields `http:<status>` for a known
|
|
176
198
|
* status, else `http:other`.
|
|
199
|
+
* The InstallTreeTornError branch is the deliberate exception: it returns a
|
|
200
|
+
* fully explicit `["InstallTreeTornError", "outcome:<token>"]` with NO
|
|
201
|
+
* DEFAULT_GROUPING lead, because its chained loader cause makes the default
|
|
202
|
+
* component unbounded (see the module header — HQ-CLI-21/22/23).
|
|
177
203
|
*
|
|
178
204
|
* `rpcCode` is checked first so a gateway JSON-RPC fault groups by its protocol
|
|
179
205
|
* code; the two carriers are disjoint in practice (the JSON-RPC throw site sets
|
|
@@ -214,8 +240,22 @@ export function sentryFingerprintFor(err) {
|
|
|
214
240
|
}
|
|
215
241
|
// A torn-install failure carries neither an rpcCode nor an HTTP status; its
|
|
216
242
|
// only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
|
|
217
|
-
// BEFORE the rpc/status branches so the family groups into
|
|
218
|
-
// (guarded / unsettled / reexec-failed / lock-held
|
|
243
|
+
// BEFORE the rpc/status branches so the family groups into a bounded set of
|
|
244
|
+
// predictable issues (guarded / unsettled / reexec-failed / lock-held, plus the
|
|
245
|
+
// `outcome:other` fallback) rather than one fungible bucket.
|
|
246
|
+
//
|
|
247
|
+
// The key is FULLY EXPLICIT — it does NOT lead with DEFAULT_GROUPING. Leading
|
|
248
|
+
// with the placeholder salts the appended tokens with Sentry's default grouping
|
|
249
|
+
// component, and THIS carrier's default component is UNBOUNDED: it wraps a
|
|
250
|
+
// chained loader cause whose stack differs on every occurrence (the dependency
|
|
251
|
+
// file a concurrent global reinstall was mid-rewrite, under each machine's
|
|
252
|
+
// absolute install path). With the placeholder lead, three events carrying the
|
|
253
|
+
// byte-identical array ["{{ default }}", "InstallTreeTornError",
|
|
254
|
+
// "outcome:unsettled"] still split into three Sentry groups —
|
|
255
|
+
// a4289a5e530e166ebdedea9d34dcad0a, b563c1b891bd975bdc605efc86034b44 and
|
|
256
|
+
// b4ee254473ea249758617de1a98848fa (HQ-CLI-21/22/23) — defeating the very bound
|
|
257
|
+
// this branch exists to enforce. The explicit key collapses them to one stable
|
|
258
|
+
// group per outcome, independent of machine, install path and which module tore.
|
|
219
259
|
if (rawName === "InstallTreeTornError") {
|
|
220
260
|
const diagnostics = record.diagnostics;
|
|
221
261
|
const outcome = diagnostics !== null && typeof diagnostics === "object"
|
|
@@ -224,7 +264,7 @@ export function sentryFingerprintFor(err) {
|
|
|
224
264
|
const token = typeof outcome === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(outcome)
|
|
225
265
|
? `outcome:${outcome}`
|
|
226
266
|
: "outcome:other";
|
|
227
|
-
return [
|
|
267
|
+
return ["InstallTreeTornError", token];
|
|
228
268
|
}
|
|
229
269
|
const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
|
|
230
270
|
const rpcCode = record.rpcCode;
|