@gajae-code/utils 0.17.1 → 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 {
@@ -11,6 +11,8 @@
11
11
  * move data to the new locations. No filesystem existence checks are performed
12
12
  * — if the env var is set, gjc trusts that the migration has been done.
13
13
  */
14
+ import { canonicalEnvKey } from "./env-file";
15
+ export { canonicalEnvKey };
14
16
  /** App name (e.g. "gjc") */
15
17
  export declare const APP_NAME: string;
16
18
  /** Config directory name (e.g. ".gjc") */
@@ -50,14 +52,6 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
50
52
  export declare function getProjectDir(): string;
51
53
  /** Set the project directory. */
52
54
  export declare function setProjectDir(dir: string): void;
53
- /**
54
- * Windows environment variable names are case-insensitive, so a project dotenv
55
- * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
56
- * provenance lookup here is spelled in upper case, so the snapshot must be
57
- * keyed the same way or the declaration is invisible to the guard while still
58
- * being live in the process. POSIX names are case-sensitive and must not fold.
59
- */
60
- export declare function canonicalEnvKey(name: string): string;
61
55
  export declare function getConfigAgentDirName(): string;
62
56
  /** Get the config root directory (~/.gjc). */
63
57
  export declare function getConfigRootDir(): string;
@@ -109,6 +103,29 @@ export declare function getReportsDir(): string;
109
103
  export declare function getLogsDir(): string;
110
104
  /** Get the path to a dated log file (~/.gjc/logs/gjc.YYYY-MM-DD.log). */
111
105
  export declare function getLogPath(date?: Date): string;
106
+ /**
107
+ * The logs directory the process actually reads and writes.
108
+ *
109
+ * A trusted `GJC_LOG_DIR` redirects the sink — the test preload pins it to a
110
+ * per-process temp directory so `bun test` stops appending fixture
111
+ * `level:error` records to the operator's shared log (issue #5618) — and
112
+ * everything else falls back to {@link getLogsDir}.
113
+ *
114
+ * The override is provenance-checked through the same {@link trustedValue} rule
115
+ * as the config and agent directories: Bun loads `cwd/.env` into `process.env`
116
+ * before any module runs, so a repository could otherwise redirect where the
117
+ * operator's production logs are written. A dynamic (`$`/backtick) declaration
118
+ * is rejected there too, which fails closed onto the canonical path.
119
+ *
120
+ * This is deliberately *not* folded into {@link getLogsDir}: that function's
121
+ * config-root semantics are pinned by tests, and the preload sets `GJC_LOG_DIR`
122
+ * for every test process, so folding the override in would move it under every
123
+ * test. Writers and readers must both come through here instead — the split
124
+ * between the two is what let the transport and the log readers disagree.
125
+ */
126
+ export declare function getEffectiveLogsDir(): string;
127
+ /** Get the dated log file under {@link getEffectiveLogsDir}. */
128
+ export declare function getEffectiveLogPath(date?: Date): string;
112
129
  /**
113
130
  * Get the plugins directory (~/.gjc/plugins or its XDG equivalent).
114
131
  *
@@ -32,3 +32,40 @@ export declare function parseShellEnvFile(filePath: string): Record<string, stri
32
32
  export declare function parseEnvFile(filePath: string): Record<string, string>;
33
33
  /** Parse dotenv content that has already been read from a trusted file. */
34
34
  export declare function parseEnvFileContent(content: string): Record<string, string>;
35
+ /**
36
+ * What the caller's checkout declares through its dotenv files.
37
+ *
38
+ * `values` is the merged declaration set, later layers winning. `dynamic` holds
39
+ * the keys whose surviving declaration is one Bun expands at load time, which
40
+ * every provenance guard refuses outright because a value comparison cannot see
41
+ * what such a declaration became.
42
+ */
43
+ export interface ProjectEnvSnapshot {
44
+ values: Record<string, string>;
45
+ dynamic: Set<string>;
46
+ }
47
+ /**
48
+ * Windows environment variable names are case-insensitive, so a project dotenv
49
+ * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
50
+ * provenance lookup is spelled in upper case, so the snapshot must be keyed the
51
+ * same way or the declaration is invisible to the guard while still being live
52
+ * in the process. POSIX names are case-sensitive and must not fold.
53
+ */
54
+ export declare function canonicalEnvKey(name: string): string;
55
+ /**
56
+ * The layered dotenv declarations Bun overlays into `process.env` for a cwd.
57
+ *
58
+ * Lives in this leaf module so every consumer shares ONE notion of provenance.
59
+ * `dirs.ts` resolves the config, agent and log directories from it, and
60
+ * `scripts/test-preload.ts` decides test isolation from it — a second, narrower
61
+ * reader (only `cwd/.env`, its own regex, its own dynamic test) is how a
62
+ * `GJC_LOG_DIR` declared in `.env.local` / `.env.$NODE_ENV` came to be honored
63
+ * by the preload and then rejected in production, silently routing test log
64
+ * records to the operator's canonical sink. This module imports only `node:fs`,
65
+ * `node:path` and the import-free `./spawn-env`, so importing it has no side
66
+ * effects and cannot freeze resolver state the way importing `dirs.ts` would.
67
+ *
68
+ * `.env.local` is deliberately skipped when `NODE_ENV === "test"`, matching the
69
+ * convention that a local override file is not part of a test run.
70
+ */
71
+ export declare function projectEnvSnapshot(cwd?: string): ProjectEnvSnapshot;
@@ -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.1",
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.1",
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,7 +16,16 @@ 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 { parseEnvFile } from "./env-file";
19
+ import { resolveCanonicalLogsDir } from "./canonical-log-dir";
20
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "./env-file";
21
+
22
+ // The provenance snapshot and its key fold live in the leaf `env-file` module so
23
+ // `scripts/test-preload.ts` can share this exact logic without importing this
24
+ // module (whose load-time resolver construction would freeze state before the
25
+ // preload sets its isolation variables). Re-exported here because `env.ts` and
26
+ // `test/env-provenance.windows.test.ts` import `canonicalEnvKey` from this
27
+ // module's public surface.
28
+ export { canonicalEnvKey };
20
29
 
21
30
  /** App name (e.g. "gjc") */
22
31
  export const APP_NAME: string = "gjc";
@@ -170,39 +179,6 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
170
179
  return trimmed;
171
180
  }
172
181
 
173
- /**
174
- * Windows environment variable names are case-insensitive, so a project dotenv
175
- * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
176
- * provenance lookup here is spelled in upper case, so the snapshot must be
177
- * keyed the same way or the declaration is invisible to the guard while still
178
- * being live in the process. POSIX names are case-sensitive and must not fold.
179
- */
180
- export function canonicalEnvKey(name: string): string {
181
- return process.platform === "win32" ? name.toUpperCase() : name;
182
- }
183
-
184
- function projectEnvSnapshot(cwd = process.cwd()): { values: Record<string, string>; dynamic: Set<string> } {
185
- const nodeEnv = process.env.NODE_ENV;
186
- const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
187
- const files = [
188
- ".env",
189
- ...(validNodeEnv ? [`.env.${validNodeEnv}`] : []),
190
- ...(validNodeEnv !== "test" ? [".env.local"] : []),
191
- ...(validNodeEnv ? [`.env.${validNodeEnv}.local`] : []),
192
- ];
193
- const values: Record<string, string> = {};
194
- const dynamic = new Set<string>();
195
- for (const file of files) {
196
- for (const [rawKey, value] of Object.entries(parseEnvFile(path.join(cwd, file)))) {
197
- const key = canonicalEnvKey(rawKey);
198
- values[key] = value;
199
- if (/[$`]/.test(value)) dynamic.add(key);
200
- else dynamic.delete(key);
201
- }
202
- }
203
- return { values, dynamic };
204
- }
205
-
206
182
  /**
207
183
  * Resolve an environment value only when it is not supplied by the caller's
208
184
  * project dotenv (or when the inherited value is observably distinct).
@@ -220,10 +196,7 @@ function projectEnvSnapshot(cwd = process.cwd()): { values: Record<string, strin
220
196
  * happens to carry the identical value loses the override, which is the same
221
197
  * trade-off `resolveLiveCredentialEnvValue` already makes.
222
198
  */
223
- function trustedValue(
224
- name: string,
225
- project: { values: Record<string, string>; dynamic: Set<string> },
226
- ): string | undefined {
199
+ function trustedValue(name: string, project: ProjectEnvSnapshot): string | undefined {
227
200
  const value = process.env[name];
228
201
  if (!value) return undefined;
229
202
  const key = canonicalEnvKey(name);
@@ -232,7 +205,7 @@ function trustedValue(
232
205
  return value;
233
206
  }
234
207
 
235
- function resolveConfigDirName(project: { values: Record<string, string>; dynamic: Set<string> }): string {
208
+ function resolveConfigDirName(project: ProjectEnvSnapshot): string {
236
209
  return (
237
210
  sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", project)) ??
238
211
  sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", project)) ??
@@ -369,7 +342,7 @@ function accountHomeFromSystem(): AccountHome | undefined {
369
342
  * per call is what makes the contract call-time; the provenance comparison above
370
343
  * is what keeps an untrusted mutable home from being honored.
371
344
  */
372
- function resolveTrustedHome(project: { values: Record<string, string>; dynamic: Set<string> }): string {
345
+ function resolveTrustedHome(project: ProjectEnvSnapshot): string {
373
346
  const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
374
347
  const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
375
348
  const declaredHome = project.values[declaredHomeKey];
@@ -425,7 +398,7 @@ type XdgCategory = "data" | "state" | "cache";
425
398
  class DirResolver {
426
399
  configRoot: string;
427
400
  agentDir: string;
428
- readonly #projectEnv: { values: Record<string, string>; dynamic: Set<string> };
401
+ readonly #projectEnv: ProjectEnvSnapshot;
429
402
  #configDirName: string;
430
403
  readonly #agentDirOverride: boolean;
431
404
  #trustedHome: string;
@@ -491,10 +464,7 @@ class DirResolver {
491
464
  * change storage lane when a home refresh made its path coincide with the
492
465
  * new default.
493
466
  */
494
- private refreshCategoryDirs(
495
- snapshot: { values: Record<string, string>; dynamic: Set<string> },
496
- isDefault: boolean,
497
- ): void {
467
+ private refreshCategoryDirs(snapshot: ProjectEnvSnapshot, isDefault: boolean): void {
498
468
  let xdgData: string | undefined;
499
469
  let xdgState: string | undefined;
500
470
  let xdgCache: string | undefined;
@@ -619,7 +589,7 @@ class DirResolver {
619
589
  this.refreshConfigDirOverride();
620
590
  if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
621
591
  }
622
- get trustSnapshot(): { values: Record<string, string>; dynamic: Set<string> } {
592
+ get trustSnapshot(): ProjectEnvSnapshot {
623
593
  return this.#projectEnv;
624
594
  }
625
595
  }
@@ -732,12 +702,58 @@ export function getReportsDir(): string {
732
702
 
733
703
  /** Get the logs directory (~/.gjc/logs). */
734
704
  export function getLogsDir(): string {
735
- 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
+ });
717
+ }
718
+
719
+ /** Dated log file name, shared by the canonical and effective log paths so they cannot drift. */
720
+ function datedLogFileName(date: Date): string {
721
+ return `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`;
736
722
  }
737
723
 
738
724
  /** Get the path to a dated log file (~/.gjc/logs/gjc.YYYY-MM-DD.log). */
739
725
  export function getLogPath(date = new Date()): string {
740
- return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
726
+ return path.join(getLogsDir(), datedLogFileName(date));
727
+ }
728
+
729
+ /**
730
+ * The logs directory the process actually reads and writes.
731
+ *
732
+ * A trusted `GJC_LOG_DIR` redirects the sink — the test preload pins it to a
733
+ * per-process temp directory so `bun test` stops appending fixture
734
+ * `level:error` records to the operator's shared log (issue #5618) — and
735
+ * everything else falls back to {@link getLogsDir}.
736
+ *
737
+ * The override is provenance-checked through the same {@link trustedValue} rule
738
+ * as the config and agent directories: Bun loads `cwd/.env` into `process.env`
739
+ * before any module runs, so a repository could otherwise redirect where the
740
+ * operator's production logs are written. A dynamic (`$`/backtick) declaration
741
+ * is rejected there too, which fails closed onto the canonical path.
742
+ *
743
+ * This is deliberately *not* folded into {@link getLogsDir}: that function's
744
+ * config-root semantics are pinned by tests, and the preload sets `GJC_LOG_DIR`
745
+ * for every test process, so folding the override in would move it under every
746
+ * test. Writers and readers must both come through here instead — the split
747
+ * between the two is what let the transport and the log readers disagree.
748
+ */
749
+ export function getEffectiveLogsDir(): string {
750
+ const override = trustedValue("GJC_LOG_DIR", dirs.trustSnapshot)?.trim();
751
+ return override || getLogsDir();
752
+ }
753
+
754
+ /** Get the dated log file under {@link getEffectiveLogsDir}. */
755
+ export function getEffectiveLogPath(date = new Date()): string {
756
+ return path.join(getEffectiveLogsDir(), datedLogFileName(date));
741
757
  }
742
758
 
743
759
  /**
package/src/env-file.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * below both of them.
7
7
  */
8
8
  import * as fs from "node:fs";
9
+ import * as path from "node:path";
9
10
  import { isSafeEnvValue } from "./spawn-env";
10
11
 
11
12
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -155,3 +156,65 @@ export function parseEnvFileContent(content: string): Record<string, string> {
155
156
 
156
157
  return result;
157
158
  }
159
+
160
+ /**
161
+ * What the caller's checkout declares through its dotenv files.
162
+ *
163
+ * `values` is the merged declaration set, later layers winning. `dynamic` holds
164
+ * the keys whose surviving declaration is one Bun expands at load time, which
165
+ * every provenance guard refuses outright because a value comparison cannot see
166
+ * what such a declaration became.
167
+ */
168
+ export interface ProjectEnvSnapshot {
169
+ values: Record<string, string>;
170
+ dynamic: Set<string>;
171
+ }
172
+
173
+ /**
174
+ * Windows environment variable names are case-insensitive, so a project dotenv
175
+ * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
176
+ * provenance lookup is spelled in upper case, so the snapshot must be keyed the
177
+ * same way or the declaration is invisible to the guard while still being live
178
+ * in the process. POSIX names are case-sensitive and must not fold.
179
+ */
180
+ export function canonicalEnvKey(name: string): string {
181
+ return process.platform === "win32" ? name.toUpperCase() : name;
182
+ }
183
+
184
+ /**
185
+ * The layered dotenv declarations Bun overlays into `process.env` for a cwd.
186
+ *
187
+ * Lives in this leaf module so every consumer shares ONE notion of provenance.
188
+ * `dirs.ts` resolves the config, agent and log directories from it, and
189
+ * `scripts/test-preload.ts` decides test isolation from it — a second, narrower
190
+ * reader (only `cwd/.env`, its own regex, its own dynamic test) is how a
191
+ * `GJC_LOG_DIR` declared in `.env.local` / `.env.$NODE_ENV` came to be honored
192
+ * by the preload and then rejected in production, silently routing test log
193
+ * records to the operator's canonical sink. This module imports only `node:fs`,
194
+ * `node:path` and the import-free `./spawn-env`, so importing it has no side
195
+ * effects and cannot freeze resolver state the way importing `dirs.ts` would.
196
+ *
197
+ * `.env.local` is deliberately skipped when `NODE_ENV === "test"`, matching the
198
+ * convention that a local override file is not part of a test run.
199
+ */
200
+ export function projectEnvSnapshot(cwd = process.cwd()): ProjectEnvSnapshot {
201
+ const nodeEnv = process.env.NODE_ENV;
202
+ const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
203
+ const files = [
204
+ ".env",
205
+ ...(validNodeEnv ? [`.env.${validNodeEnv}`] : []),
206
+ ...(validNodeEnv !== "test" ? [".env.local"] : []),
207
+ ...(validNodeEnv ? [`.env.${validNodeEnv}.local`] : []),
208
+ ];
209
+ const values: Record<string, string> = {};
210
+ const dynamic = new Set<string>();
211
+ for (const file of files) {
212
+ for (const [rawKey, value] of Object.entries(parseEnvFile(path.join(cwd, file)))) {
213
+ const key = canonicalEnvKey(rawKey);
214
+ values[key] = value;
215
+ if (/[$`]/.test(value)) dynamic.add(key);
216
+ else dynamic.delete(key);
217
+ }
218
+ }
219
+ return { values, dynamic };
220
+ }
@@ -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/logger.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import * as fs from "node:fs";
14
14
  import type * as winston from "winston";
15
- import { getLogsDir } from "./dirs";
15
+ import { getEffectiveLogsDir } from "./dirs";
16
16
 
17
17
  /** Ensure a logs directory exists; return the resolved path. */
18
18
  function ensureDir(dir: string): string {
@@ -73,10 +73,22 @@ function makeLogFormat(winston: WinstonModule): winston.Logform.Format {
73
73
  );
74
74
  }
75
75
 
76
- /** Build a rotating file transport, materializing the target directory lazily. */
76
+ /**
77
+ * Build a rotating file transport, materializing the target directory lazily.
78
+ *
79
+ * Destination precedence:
80
+ * 1. `dir` — an explicit path from {@link setTransports}(`{ file: "<path>" }`).
81
+ * 2. {@link getEffectiveLogsDir} — the provenance-checked `GJC_LOG_DIR`
82
+ * override, else the real config root (`~/.gjc/logs`).
83
+ *
84
+ * The resolution is centralized in `dirs.ts` rather than read from the
85
+ * environment here: the log *readers* (report bundles, the debug log view, the
86
+ * HTTP dump directory) call the same helper, and a second env read in this file
87
+ * is what let the transport write somewhere the readers never looked.
88
+ */
77
89
  function makeFileTransport(DailyRotateFile: DailyRotateFileCtor, dir?: string): Transport {
78
90
  return new DailyRotateFile({
79
- dirname: ensureDir(dir ?? getLogsDir()),
91
+ dirname: ensureDir(dir ?? getEffectiveLogsDir()),
80
92
  filename: "gjc.%DATE%.log",
81
93
  datePattern: "YYYY-MM-DD",
82
94
  maxSize: "10m",
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 {