@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.
- package/dist/types/canonical-log-dir.d.ts +20 -0
- package/dist/types/cli.d.ts +8 -0
- package/dist/types/crash-fingerprint.d.ts +9 -0
- package/dist/types/dirs.d.ts +25 -8
- package/dist/types/env-file.d.ts +37 -0
- package/dist/types/error-classification.d.ts +12 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/postmortem.d.ts +2 -0
- package/dist/types/sanitize-text.d.ts +2 -1
- package/package.json +2 -2
- package/src/canonical-log-dir.ts +59 -0
- package/src/cli.ts +15 -0
- package/src/crash-fingerprint.ts +30 -6
- package/src/dirs.ts +64 -48
- package/src/env-file.ts +63 -0
- package/src/error-classification.ts +31 -0
- package/src/index.ts +1 -0
- package/src/logger.ts +15 -3
- package/src/postmortem.ts +18 -4
- package/src/sanitize-text.ts +4 -2
|
@@ -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 {};
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -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 {
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/types/env-file.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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("--");
|
package/src/crash-fingerprint.ts
CHANGED
|
@@ -224,17 +224,18 @@ function canonicalSerialization(fields: readonly string[]): Buffer {
|
|
|
224
224
|
return Buffer.concat(parts);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
.
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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():
|
|
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
|
-
|
|
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(),
|
|
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
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 {
|
|
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
|
-
/**
|
|
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 ??
|
|
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 {
|
|
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 (
|
|
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 =
|
|
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
|
package/src/sanitize-text.ts
CHANGED
|
@@ -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 {
|