@gajae-code/utils 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ interface ProjectEnvSnapshotLike {
2
+ values: Record<string, string>;
3
+ dynamic: Set<string>;
4
+ }
5
+ interface CanonicalLogDirInput {
6
+ home: string;
7
+ env: Record<string, string | undefined>;
8
+ projectEnv: ProjectEnvSnapshotLike;
9
+ xdgEligible: boolean;
10
+ pathExists?: (target: string) => boolean;
11
+ }
12
+ /**
13
+ * Resolve the canonical user log directory without mutating the environment.
14
+ *
15
+ * The caller supplies filesystem existence checks so this leaf stays free of
16
+ * filesystem and resolver side effects. Both the production directory resolver
17
+ * and the test preload use this exact path selection logic.
18
+ */
19
+ export declare function resolveCanonicalLogsDir(input: CanonicalLogDirInput): string;
20
+ export {};
@@ -111,6 +111,14 @@ export interface CommandEntry {
111
111
  name: string;
112
112
  load: () => Promise<CommandCtor>;
113
113
  aliases?: string[];
114
+ /** Owns parsing, help, loading and failures for this family when supplied. */
115
+ dispatch?: (argv: string[], context: CommandEntryContext) => Promise<void>;
116
+ }
117
+ export interface CommandEntryContext {
118
+ bin: string;
119
+ version: string;
120
+ /** Canonical registered entry name, including when invoked through an alias. */
121
+ command: string;
114
122
  }
115
123
  export interface RunOptions {
116
124
  bin: string;
@@ -59,6 +59,15 @@ export declare function normalizeCrashMessage(message: string, options?: CrashFi
59
59
  export declare function normalizeCrashFrames(stack: string, options?: CrashFingerprintOptions): string[];
60
60
  /** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
61
61
  export declare function computeCrashFingerprint(input: CrashFingerprintInput, options?: CrashFingerprintOptions): CrashFingerprint;
62
+ /**
63
+ * Compute the stable identity used for a handled tool error.
64
+ *
65
+ * Tool failures often carry command output or other per-occurrence detail in
66
+ * their message. That text is useful in the record body but is not the failure
67
+ * identity: handled errors group by error class and the first in-app frame
68
+ * where the failure originated, rather than the full wrapper stack.
69
+ */
70
+ export declare function computeHandledErrorFingerprint(input: CrashFingerprintInput, options?: CrashFingerprintOptions): CrashFingerprint;
62
71
  /** The machine-readable identity line appended to every new crash record. */
63
72
  export declare function formatCrashRecordMarker(fingerprint: string, version: number, recordId: string): string;
64
73
  export interface CrashRecordMarker {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Cross-package classification for errors that describe an expected outcome
3
+ * rather than an unexpected failure.
4
+ *
5
+ * The symbol is registered globally so the marker survives package duplication
6
+ * and worker/VM boundaries without relying on a particular Error constructor.
7
+ */
8
+ export declare const DESIGNED_ERROR: unique symbol;
9
+ /** Mark an Error as a designed outcome before it crosses package boundaries. */
10
+ export declare function markDesignedError<T extends Error>(error: T): T;
11
+ /** Return true when a throwable carries the trusted designed-outcome marker. */
12
+ export declare function isDesignedError(error: unknown): boolean;
@@ -7,6 +7,7 @@ export * from "./crash-journal";
7
7
  export * from "./crash-redaction";
8
8
  export * from "./dirs";
9
9
  export * from "./env";
10
+ export * from "./error-classification";
10
11
  export * from "./fetch-retry";
11
12
  export * from "./format";
12
13
  export * from "./frontmatter";
@@ -5,6 +5,7 @@
5
5
  * in response to process exit, signals, or fatal exceptions. It is intended to
6
6
  * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
7
  */
8
+ import { type CrashFingerprint } from "./crash-fingerprint";
8
9
  import type { CrashProvenance } from "./crash-journal";
9
10
  import { redactCrashSecrets } from "./crash-redaction";
10
11
  export declare enum Reason {
@@ -63,6 +64,7 @@ interface CrashRecordOptions {
63
64
  path?: string;
64
65
  now?: Date;
65
66
  provenance?: CrashProvenance;
67
+ fingerprint?: CrashFingerprint;
66
68
  }
67
69
  /**
68
70
  * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
@@ -18,6 +18,7 @@ export declare function sanitizeText(text: string): string;
18
18
  * {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
19
19
  * treats it as zero-width, so a value carrying line breaks can still inject
20
20
  * extra rows and evade a single-line width budget. Flatten every CR/LF run to a
21
- * single space before the usual control/ANSI strip.
21
+ * single space before the usual control/ANSI strip, then remove directional
22
+ * format controls that can visually reorder an otherwise safe row.
22
23
  */
23
24
  export declare function sanitizeDisplayLine(text: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.17.2",
4
+ "version": "0.17.4",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.17.2",
34
+ "@gajae-code/natives": "0.17.4",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
@@ -0,0 +1,59 @@
1
+ import * as path from "node:path";
2
+
3
+ interface ProjectEnvSnapshotLike {
4
+ values: Record<string, string>;
5
+ dynamic: Set<string>;
6
+ }
7
+
8
+ interface CanonicalLogDirInput {
9
+ home: string;
10
+ env: Record<string, string | undefined>;
11
+ projectEnv: ProjectEnvSnapshotLike;
12
+ xdgEligible: boolean;
13
+ pathExists?: (target: string) => boolean;
14
+ }
15
+
16
+ const APP_NAME = "gjc";
17
+ const DEFAULT_CONFIG_DIR_NAME = ".gjc";
18
+
19
+ function canonicalEnvKey(name: string): string {
20
+ return process.platform === "win32" ? name.toUpperCase() : name;
21
+ }
22
+
23
+ function sanitizeConfigDirName(value: string | undefined): string | undefined {
24
+ const trimmed = value?.trim();
25
+ if (!trimmed || path.normalize(trimmed).split(/[\\/]/).includes("..")) return undefined;
26
+ return trimmed;
27
+ }
28
+
29
+ /** Resolve a caller environment value only when its dotenv provenance is trusted. */
30
+ function trustedValue(name: string, input: CanonicalLogDirInput): string | undefined {
31
+ const value = input.env[name];
32
+ if (!value) return undefined;
33
+ const key = canonicalEnvKey(name);
34
+ if (input.projectEnv.dynamic.has(key) || input.projectEnv.values[key] === value) return undefined;
35
+ return value;
36
+ }
37
+
38
+ /**
39
+ * Resolve the canonical user log directory without mutating the environment.
40
+ *
41
+ * The caller supplies filesystem existence checks so this leaf stays free of
42
+ * filesystem and resolver side effects. Both the production directory resolver
43
+ * and the test preload use this exact path selection logic.
44
+ */
45
+ export function resolveCanonicalLogsDir(input: CanonicalLogDirInput): string {
46
+ const configDirName =
47
+ sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", input)) ??
48
+ sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", input)) ??
49
+ DEFAULT_CONFIG_DIR_NAME;
50
+ const xdgStateHome =
51
+ input.xdgEligible && (process.platform === "linux" || process.platform === "darwin")
52
+ ? trustedValue("XDG_STATE_HOME", input)?.trim()
53
+ : undefined;
54
+ if (xdgStateHome) {
55
+ const xdgRoot = path.join(xdgStateHome, APP_NAME);
56
+ if (input.pathExists?.(xdgRoot) === true) return path.join(xdgRoot, "logs");
57
+ }
58
+ return path.join(input.home, configDirName, "logs");
59
+ }
package/src/cli.ts CHANGED
@@ -394,6 +394,15 @@ export interface CommandEntry {
394
394
  name: string;
395
395
  load: () => Promise<CommandCtor>;
396
396
  aliases?: string[];
397
+ /** Owns parsing, help, loading and failures for this family when supplied. */
398
+ dispatch?: (argv: string[], context: CommandEntryContext) => Promise<void>;
399
+ }
400
+
401
+ export interface CommandEntryContext {
402
+ bin: string;
403
+ version: string;
404
+ /** Canonical registered entry name, including when invoked through an alias. */
405
+ command: string;
397
406
  }
398
407
 
399
408
  export interface RunOptions {
@@ -439,6 +448,12 @@ export async function run(opts: RunOptions): Promise<void> {
439
448
  return;
440
449
  }
441
450
 
451
+ const dispatchedEntry = findEntry(opts.commands, commandId);
452
+ if (dispatchedEntry?.dispatch) {
453
+ await dispatchedEntry.dispatch(commandArgv, { bin, version, command: dispatchedEntry.name });
454
+ return;
455
+ }
456
+
442
457
  // Per-command help. Commands with nested subcommands can opt into receiving
443
458
  // help flags themselves so `cmd subcommand --help` can render subcommand help.
444
459
  const delimiterIndex = commandArgv.indexOf("--");
@@ -224,17 +224,18 @@ function canonicalSerialization(fields: readonly string[]): Buffer {
224
224
  return Buffer.concat(parts);
225
225
  }
226
226
 
227
- /** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
228
- export function computeCrashFingerprint(
227
+ function computeFingerprint(
229
228
  input: CrashFingerprintInput,
230
- options: CrashFingerprintOptions = {},
229
+ options: CrashFingerprintOptions,
230
+ includeMessage: boolean,
231
231
  ): CrashFingerprint {
232
232
  const errorName = truncateUtf8(normalizeCrashMessage(input.name, options) || "Error", 128);
233
233
  const messageClass = normalizeCrashMessage(input.message, options);
234
234
  const frames = normalizeCrashFrames(input.stack, options);
235
- const digest = createHash("sha256")
236
- .update(canonicalSerialization(["gjc-crash-fp.v1", errorName, messageClass, ...frames]))
237
- .digest();
235
+ const identity = includeMessage
236
+ ? ["gjc-crash-fp.v1", errorName, messageClass, ...frames]
237
+ : ["gjc-crash-fp.v1", "handled", errorName, frames[0] ?? NO_APP_FRAME];
238
+ const digest = createHash("sha256").update(canonicalSerialization(identity)).digest();
238
239
  return {
239
240
  fingerprint: digest.subarray(0, CRASH_FINGERPRINT_HEX_LENGTH / 2).toString("hex"),
240
241
  version: CRASH_FINGERPRINT_VERSION,
@@ -244,6 +245,29 @@ export function computeCrashFingerprint(
244
245
  };
245
246
  }
246
247
 
248
+ /** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
249
+ export function computeCrashFingerprint(
250
+ input: CrashFingerprintInput,
251
+ options: CrashFingerprintOptions = {},
252
+ ): CrashFingerprint {
253
+ return computeFingerprint(input, options, true);
254
+ }
255
+
256
+ /**
257
+ * Compute the stable identity used for a handled tool error.
258
+ *
259
+ * Tool failures often carry command output or other per-occurrence detail in
260
+ * their message. That text is useful in the record body but is not the failure
261
+ * identity: handled errors group by error class and the first in-app frame
262
+ * where the failure originated, rather than the full wrapper stack.
263
+ */
264
+ export function computeHandledErrorFingerprint(
265
+ input: CrashFingerprintInput,
266
+ options: CrashFingerprintOptions = {},
267
+ ): CrashFingerprint {
268
+ return computeFingerprint(input, options, false);
269
+ }
270
+
247
271
  /** The machine-readable identity line appended to every new crash record. */
248
272
  export function formatCrashRecordMarker(fingerprint: string, version: number, recordId: string): string {
249
273
  return `${CRASH_RECORD_MARKER} fp:${fingerprint} fpv:${version} id:${recordId}`;
package/src/dirs.ts CHANGED
@@ -16,6 +16,7 @@ import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
18
  import { engines, version } from "../package.json" with { type: "json" };
19
+ import { resolveCanonicalLogsDir } from "./canonical-log-dir";
19
20
  import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "./env-file";
20
21
 
21
22
  // The provenance snapshot and its key fold live in the leaf `env-file` module so
@@ -701,7 +702,18 @@ export function getReportsDir(): string {
701
702
 
702
703
  /** Get the logs directory (~/.gjc/logs). */
703
704
  export function getLogsDir(): string {
704
- return dirs.rootSubdir("logs", "state");
705
+ const home = getTrustedHomeDir();
706
+ return resolveCanonicalLogsDir({
707
+ home,
708
+ env: {
709
+ GJC_CONFIG_DIR: process.env.GJC_CONFIG_DIR,
710
+ PI_CONFIG_DIR: process.env.PI_CONFIG_DIR,
711
+ XDG_STATE_HOME: process.env.XDG_STATE_HOME,
712
+ },
713
+ projectEnv: dirs.trustSnapshot,
714
+ xdgEligible: dirs.profileAuthority === "default",
715
+ pathExists: fs.existsSync,
716
+ });
705
717
  }
706
718
 
707
719
  /** Dated log file name, shared by the canonical and effective log paths so they cannot drift. */
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cross-package classification for errors that describe an expected outcome
3
+ * rather than an unexpected failure.
4
+ *
5
+ * The symbol is registered globally so the marker survives package duplication
6
+ * and worker/VM boundaries without relying on a particular Error constructor.
7
+ */
8
+ export const DESIGNED_ERROR = Symbol.for("gajae-code.designed-error");
9
+
10
+ type MarkedError = object & { readonly [DESIGNED_ERROR]?: unknown };
11
+
12
+ /** Mark an Error as a designed outcome before it crosses package boundaries. */
13
+ export function markDesignedError<T extends Error>(error: T): T {
14
+ Object.defineProperty(error, DESIGNED_ERROR, {
15
+ configurable: false,
16
+ enumerable: false,
17
+ value: true,
18
+ writable: false,
19
+ });
20
+ return error;
21
+ }
22
+
23
+ /** Return true when a throwable carries the trusted designed-outcome marker. */
24
+ export function isDesignedError(error: unknown): boolean {
25
+ if ((typeof error !== "object" && typeof error !== "function") || error === null) return false;
26
+ try {
27
+ return (error as MarkedError)[DESIGNED_ERROR] === true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./crash-journal";
7
7
  export * from "./crash-redaction";
8
8
  export * from "./dirs";
9
9
  export * from "./env";
10
+ export * from "./error-classification";
10
11
  export * from "./fetch-retry";
11
12
  export * from "./format";
12
13
  export * from "./frontmatter";
package/src/postmortem.ts CHANGED
@@ -12,11 +12,17 @@ import inspector from "node:inspector";
12
12
  import * as path from "node:path";
13
13
  import { isMainThread } from "node:worker_threads";
14
14
  import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
15
- import { type CrashFingerprint, computeCrashFingerprint, formatCrashRecordMarker } from "./crash-fingerprint";
15
+ import {
16
+ type CrashFingerprint,
17
+ computeCrashFingerprint,
18
+ computeHandledErrorFingerprint,
19
+ formatCrashRecordMarker,
20
+ } from "./crash-fingerprint";
16
21
  import type { CrashProvenance } from "./crash-journal";
17
22
  import { appendCrashEvent, appendFatalCrashEvent, detectCrashProvenance } from "./crash-journal";
18
23
  import { redactCrashSecrets } from "./crash-redaction";
19
24
  import { getCrashEventsPath, getCrashLogPath, getHandledErrorEventsPath, getHandledErrorLogPath } from "./dirs";
25
+ import { isDesignedError } from "./error-classification";
20
26
  import * as logger from "./logger";
21
27
  import { safeStderrWrite } from "./safe-stderr";
22
28
 
@@ -512,9 +518,15 @@ export function recordHandledError(
512
518
  options: HandledErrorRecordOptions = {},
513
519
  ): string | undefined {
514
520
  try {
515
- if (!(error instanceof Error) || typeof error.stack !== "string" || error.stack.length === 0) return undefined;
521
+ if (
522
+ !(error instanceof Error) ||
523
+ isDesignedError(error) ||
524
+ typeof error.stack !== "string" ||
525
+ error.stack.length === 0
526
+ )
527
+ return undefined;
516
528
  const fatal = describeFatal(error);
517
- const fingerprint = computeCrashFingerprint(fatal).fingerprint;
529
+ const fingerprint = computeHandledErrorFingerprint(fatal).fingerprint;
518
530
  if (handledErrorFingerprints.has(fingerprint)) {
519
531
  // Still hot: dedupe, but refresh recency so an actively failing class
520
532
  // is not the one evicted under pressure.
@@ -530,6 +542,7 @@ export function recordHandledError(
530
542
  const written = writeCrashRecord(label, fatal, {
531
543
  path: options.path ?? getHandledErrorLogPath(),
532
544
  now: options.now,
545
+ fingerprint: computeHandledErrorFingerprint(fatal),
533
546
  });
534
547
  if (!written) {
535
548
  handledErrorFingerprints.delete(fingerprint);
@@ -562,6 +575,7 @@ interface CrashRecordOptions {
562
575
  path?: string;
563
576
  now?: Date;
564
577
  provenance?: CrashProvenance;
578
+ fingerprint?: CrashFingerprint;
565
579
  }
566
580
 
567
581
  interface WrittenCrashRecord {
@@ -595,7 +609,7 @@ function writeCrashRecord(
595
609
  const payload = fatal.payload ? `${redactCrashSecrets(fatal.payload)}\n` : "";
596
610
  // Identity is computed from the already-captured diagnostic text only; the
597
611
  // throwable is never read again here.
598
- const fingerprint = computeCrashFingerprint(fatal);
612
+ const fingerprint = options.fingerprint ?? computeCrashFingerprint(fatal);
599
613
  const recordId = randomBytes(8).toString("hex");
600
614
  const markerLine = `${formatCrashRecordMarker(fingerprint.fingerprint, fingerprint.version, recordId)}\n`;
601
615
  // The marker is the record's identity, so it is budgeted first and appended
@@ -17,6 +17,7 @@ const ESC_CHAR = "\x1b";
17
17
  // Well-formed strings only need control/ANSI detection: C0 (excl. \t \n),
18
18
  // CR, DEL, and C1. ESC (0x1B) is in \x0B-\x1F.
19
19
  const CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
20
+ const DISPLAY_FORMAT_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
20
21
 
21
22
  const REPLACEMENT_CHAR = "\ufffd";
22
23
 
@@ -34,10 +35,11 @@ export function sanitizeText(text: string): string {
34
35
  * {@link sanitizeText} deliberately preserves `\n`, and width-based truncation
35
36
  * treats it as zero-width, so a value carrying line breaks can still inject
36
37
  * extra rows and evade a single-line width budget. Flatten every CR/LF run to a
37
- * single space before the usual control/ANSI strip.
38
+ * single space before the usual control/ANSI strip, then remove directional
39
+ * format controls that can visually reorder an otherwise safe row.
38
40
  */
39
41
  export function sanitizeDisplayLine(text: string): string {
40
- return sanitizeText(text.replace(/[\r\n]+/gu, " "));
42
+ return sanitizeText(text.replace(/[\r\n]+/gu, " ")).replace(DISPLAY_FORMAT_RE, "");
41
43
  }
42
44
 
43
45
  function sanitizeWellFormedText(text: string): string {