@effected/cli-kit 0.0.0-stage → 0.26.0
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/Cancelled.js +43 -0
- package/CliAudience.js +177 -0
- package/CliColor.js +59 -0
- package/CliEnv.js +101 -0
- package/CliExit.js +64 -0
- package/CliFailure.js +287 -0
- package/CliFormat.js +124 -0
- package/CliInteractive.js +94 -0
- package/CliLinks.js +153 -0
- package/CliLog.js +367 -0
- package/CliLogger.js +91 -0
- package/CliMessage.js +79 -0
- package/CliPrompt.js +110 -0
- package/CliRuntime.js +254 -0
- package/CliTest.js +206 -0
- package/CliTheme.js +142 -0
- package/ConfigIssueRenderer.js +63 -0
- package/Doc.js +548 -0
- package/Fmt.js +335 -0
- package/GithubAnnotation.js +39 -0
- package/Glyphs.js +82 -0
- package/LICENSE +21 -0
- package/NotInteractive.js +41 -0
- package/README.md +215 -2
- package/Render.js +280 -0
- package/SchemaIssueRenderer.js +47 -0
- package/Status.js +165 -0
- package/TestTerminal.js +101 -0
- package/Token.js +68 -0
- package/index.d.ts +3951 -0
- package/index.js +27 -0
- package/internal/ExitRequested.js +21 -0
- package/internal/HelpRouting.js +35 -0
- package/internal/ansi.js +229 -0
- package/internal/autoFormat.js +33 -0
- package/internal/canPrompt.js +14 -0
- package/internal/counts.js +83 -0
- package/internal/diagnostics.js +31 -0
- package/internal/displayPath.js +46 -0
- package/internal/displayWidth.js +42 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +17 -0
- package/internal/fileSink.js +61 -0
- package/internal/format.js +175 -0
- package/internal/inspect.js +40 -0
- package/internal/isExitCode.js +11 -0
- package/internal/layout.js +249 -0
- package/internal/linkScheme.js +29 -0
- package/internal/linkTarget.js +49 -0
- package/internal/logSafety.js +45 -0
- package/internal/renderAnsi.js +51 -0
- package/internal/renderDoc.js +322 -0
- package/internal/renderGithubLog.js +59 -0
- package/internal/renderMarkdown.js +367 -0
- package/internal/renderPlain.js +49 -0
- package/internal/scanAudience.js +105 -0
- package/internal/splitFrame.js +55 -0
- package/internal/terminalCapabilities.js +23 -0
- package/internal/wizardGate.js +17 -0
- package/package.json +90 -5
- package/testing.d.ts +246 -0
- package/testing.js +4 -0
- package/tsdoc-metadata.json +11 -0
- package/ui/CliUi.js +507 -0
- package/ui/CliUiDetached.js +36 -0
- package/ui/CliUiLive.js +436 -0
- package/ui/CliUiRouteStream.js +72 -0
- package/ui/Confirm.js +244 -0
- package/ui/DocView.js +76 -0
- package/ui/KeyHelp.js +61 -0
- package/ui/KeyTable.js +198 -0
- package/ui/MultiSelect.js +259 -0
- package/ui/Select.js +229 -0
- package/ui/Tabs.js +201 -0
- package/ui/TextInput.js +289 -0
- package/ui/Toggle.js +31 -0
- package/ui/UiKey.js +48 -0
- package/ui/UiProvider.js +62 -0
- package/ui/UiStreams.js +17 -0
- package/ui/UiTheme.js +147 -0
- package/ui/Viewport.js +203 -0
- package/ui/internal/ErrorBoundary.js +29 -0
- package/ui/internal/Holder.js +73 -0
- package/ui/internal/ScreenContext.js +51 -0
- package/ui/internal/UiProviders.js +38 -0
- package/ui/internal/ink.js +121 -0
- package/ui/internal/inkChalk.js +47 -0
- package/ui/internal/inkConsole.js +150 -0
- package/ui/internal/lazyView.js +73 -0
- package/ui/internal/lineRouter.js +70 -0
- package/ui/internal/lineText.js +18 -0
- package/ui/internal/mountPermit.js +15 -0
- package/ui/internal/processStreams.js +18 -0
- package/ui/internal/renderOptions.js +12 -0
- package/ui/testing/CliUiTest.js +792 -0
- package/ui/testing/fakeStreams.js +104 -0
- package/ui/testing/terminalModel.js +67 -0
- package/ui-testing-serializer.d.ts +12 -0
- package/ui-testing-serializer.js +32 -0
- package/ui-testing.d.ts +558 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1990 -0
- package/ui.js +17 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { CliFailure } from "../CliFailure.js";
|
|
2
|
+
import { CliLinks } from "../CliLinks.js";
|
|
3
|
+
import { CliTheme } from "../CliTheme.js";
|
|
4
|
+
import { sanitize } from "../Fmt.js";
|
|
5
|
+
import { Glyphs } from "../Glyphs.js";
|
|
6
|
+
import { Render } from "../Render.js";
|
|
7
|
+
import { autoFormat } from "./autoFormat.js";
|
|
8
|
+
import { identity } from "./displayPath.js";
|
|
9
|
+
import { Config, Context, Effect, MutableRef, Option } from "effect";
|
|
10
|
+
import { Audience, TerminalEnv } from "@effected/env";
|
|
11
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
12
|
+
|
|
13
|
+
// src/internal/failureTarget.ts
|
|
14
|
+
/**
|
|
15
|
+
* A cell `CliRuntime.main` provides outside failure reporting and fills from inside it.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* `reportFailures` catches OUTSIDE the layers `main` provides, so the theme, terminal, audience and links a report is
|
|
19
|
+
* rendered with are not in its context when a failure arrives. The environment layer writes the target here as it is
|
|
20
|
+
* built, and an audience flag rewrites it once the flag is read. A `Reference` defaulting to `undefined`, so a
|
|
21
|
+
* program run without `main` finds no cell and falls back, and no state is shared between runs.
|
|
22
|
+
*
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
const FailureTargetCell = Context.Reference("@effected/cli-kit/FailureTargetCell", { defaultValue: () => void 0 });
|
|
26
|
+
/** What a report is rendered with when nothing is known about the terminal: plain text, no limit, no escapes. */
|
|
27
|
+
const fallbackTarget = {
|
|
28
|
+
ctx: {
|
|
29
|
+
width: Number.POSITIVE_INFINITY,
|
|
30
|
+
audience: "agent",
|
|
31
|
+
color: "none",
|
|
32
|
+
paint: (_token, text) => text,
|
|
33
|
+
glyphs: Glyphs.ascii,
|
|
34
|
+
link: (_target, label) => label,
|
|
35
|
+
displayPath: identity,
|
|
36
|
+
neutralizeWorkflowCommands: true
|
|
37
|
+
},
|
|
38
|
+
format: "plain",
|
|
39
|
+
assumed: true
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The target for the services in the current context, or `undefined` when it lacks any of the four.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Each is read with `serviceOption`, so this never adds a requirement. `audience` overrides the one in context: an
|
|
46
|
+
* audience flag is provided deeper than the environment layer, where the report cannot see it.
|
|
47
|
+
*/
|
|
48
|
+
const build = (audience, settings = {}) => Effect.gen(function* () {
|
|
49
|
+
const theme = yield* Effect.serviceOption(CliTheme);
|
|
50
|
+
const terminal = yield* Effect.serviceOption(TerminalEnv);
|
|
51
|
+
const current = yield* Effect.serviceOption(Audience);
|
|
52
|
+
const links = yield* Effect.serviceOption(CliLinks);
|
|
53
|
+
if (Option.isNone(theme) || Option.isNone(terminal) || Option.isNone(links)) return void 0;
|
|
54
|
+
const shape = audience ?? (Option.isSome(current) ? current.value : void 0);
|
|
55
|
+
if (shape === void 0) return void 0;
|
|
56
|
+
const { displayPath, stackFrames, spans, appModule } = settings;
|
|
57
|
+
const ctx = yield* Render.context("stderr", displayPath === void 0 ? void 0 : { displayPath }).pipe(Effect.provideService(CliTheme, theme.value), Effect.provideService(TerminalEnv, terminal.value), Effect.provideService(CliLinks, links.value), Effect.provideService(Audience, shape));
|
|
58
|
+
return {
|
|
59
|
+
ctx,
|
|
60
|
+
format: yield* autoFormat(ctx.audience),
|
|
61
|
+
...stackFrames === void 0 ? {} : { stackFrames },
|
|
62
|
+
...spans === void 0 ? {} : { spans },
|
|
63
|
+
...appModule === void 0 ? {} : { appModule }
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Record the target for the services in context in the cell, if there is one. A no-op without a cell or services.
|
|
68
|
+
*
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
const refreshFailureTarget = (audience, settings) => Effect.gen(function* () {
|
|
72
|
+
const cell = yield* FailureTargetCell;
|
|
73
|
+
if (cell === void 0) return;
|
|
74
|
+
const recorded = MutableRef.get(cell);
|
|
75
|
+
const target = yield* build(audience, settings ?? {
|
|
76
|
+
displayPath: recorded?.ctx.displayPath,
|
|
77
|
+
stackFrames: recorded?.stackFrames,
|
|
78
|
+
spans: recorded?.spans,
|
|
79
|
+
appModule: recorded?.appModule
|
|
80
|
+
});
|
|
81
|
+
if (target !== void 0) MutableRef.set(cell, target);
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* The target a report is rendered with: the cell, else the services in context, else the plain fallback.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
const currentTarget = Effect.gen(function* () {
|
|
89
|
+
const cell = yield* FailureTargetCell;
|
|
90
|
+
const recorded = cell === void 0 ? void 0 : MutableRef.get(cell);
|
|
91
|
+
if (recorded !== void 0) return recorded;
|
|
92
|
+
return (yield* build()) ?? fallbackTarget;
|
|
93
|
+
});
|
|
94
|
+
/** Content with its leading status mark (and the space after it) removed. */
|
|
95
|
+
const dropStatus = (content) => {
|
|
96
|
+
if (content[0]?._tag !== "StatusMark") return content;
|
|
97
|
+
const next = content[1];
|
|
98
|
+
return next?._tag === "Text" && next.value === " " ? content.slice(2) : content.slice(1);
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* A failure document without its status marks. A failure's status leads its first paragraph, or a schema failure's
|
|
102
|
+
* tree label; nothing else in the document carries one.
|
|
103
|
+
*/
|
|
104
|
+
const withoutStatus = (doc) => doc.map((block) => {
|
|
105
|
+
if (block._tag === "Paragraph") return {
|
|
106
|
+
...block,
|
|
107
|
+
content: dropStatus(block.content)
|
|
108
|
+
};
|
|
109
|
+
if (block._tag === "Tree") return {
|
|
110
|
+
...block,
|
|
111
|
+
root: {
|
|
112
|
+
...block.root,
|
|
113
|
+
label: dropStatus(block.root.label)
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
return block;
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* The lines of a failure report for a target, with or without the leading status.
|
|
120
|
+
*
|
|
121
|
+
* @internal
|
|
122
|
+
*/
|
|
123
|
+
const linesOf = (cause, target, status = true, spans = target.spans) => {
|
|
124
|
+
const full = CliFailure.toDoc(cause, {
|
|
125
|
+
displayPath: target.ctx.displayPath,
|
|
126
|
+
...target.stackFrames === void 0 ? {} : { stackFrames: target.stackFrames },
|
|
127
|
+
...spans === void 0 ? {} : { spans },
|
|
128
|
+
...target.appModule === void 0 ? {} : { appModule: target.appModule }
|
|
129
|
+
});
|
|
130
|
+
const doc = status ? full : withoutStatus(full);
|
|
131
|
+
const text = Render[target.format](doc, target.ctx);
|
|
132
|
+
return text === "" ? [] : text.split("\n");
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* A consumer `render`'s lines, made safe: neutralized under GitHub Actions, and stripped of escapes for an agent.
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* What a consumer's `render` returns is text the kit did not build and cannot vouch for: it interpolates error
|
|
139
|
+
* messages, file names, whatever the failure carried. So it gets the output policy the kit's own report has. Under
|
|
140
|
+
* GitHub Actions (the target says so, and with no environment services at all it is assumed) every line is neutralized,
|
|
141
|
+
* a returned line break splitting it first. For an agent or a CI audience the escapes are removed too (GitHub Actions
|
|
142
|
+
* detects as `ci`, and the kit's own output for it has none). For a person they are kept: the kit cannot tell the consumer's own colour from an injected sequence, so the consumer's `render` is
|
|
143
|
+
* responsible for sanitising what it interpolates. An audience that was only assumed is not an agent.
|
|
144
|
+
*
|
|
145
|
+
* @internal
|
|
146
|
+
*/
|
|
147
|
+
const guardConsumerLines = (lines) => Effect.map(currentTarget, (target) => {
|
|
148
|
+
const stripped = target.assumed !== true && (target.ctx.audience === "agent" || target.ctx.audience === "ci") ? lines.map(sanitize) : lines;
|
|
149
|
+
return target.ctx.neutralizeWorkflowCommands === true ? stripped.flatMap((line) => CommandNeutralizer.lines(line)) : stripped;
|
|
150
|
+
});
|
|
151
|
+
/**
|
|
152
|
+
* The plain lines of a failure, for a caller with no services: what `CliRuntime.defaultRender` returns.
|
|
153
|
+
*
|
|
154
|
+
* @internal
|
|
155
|
+
*/
|
|
156
|
+
const plainFailureLines = (cause, status = true, spans) => linesOf(cause, fallbackTarget, status, spans);
|
|
157
|
+
const SPAN_SETTINGS = [
|
|
158
|
+
"app",
|
|
159
|
+
"all",
|
|
160
|
+
"off"
|
|
161
|
+
];
|
|
162
|
+
/**
|
|
163
|
+
* The span trail setting, as `CliLog`'s level is read: the explicit `spans` when given (the variable is then not read
|
|
164
|
+
* at all), else the variable named `envVar` through `Config`, case-insensitive, unset or empty meaning the default.
|
|
165
|
+
* A value that is not a setting is ignored, with the warning to log.
|
|
166
|
+
*
|
|
167
|
+
* @internal
|
|
168
|
+
*/
|
|
169
|
+
const readSpans = (explicit, envVar) => Effect.gen(function* () {
|
|
170
|
+
if (explicit !== void 0) return {
|
|
171
|
+
spans: explicit,
|
|
172
|
+
invalid: void 0
|
|
173
|
+
};
|
|
174
|
+
if (envVar === void 0) return {
|
|
175
|
+
spans: void 0,
|
|
176
|
+
invalid: void 0
|
|
177
|
+
};
|
|
178
|
+
const raw = yield* Config.option(Config.String(envVar)).pipe(Effect.orElseSucceed(() => Option.none()));
|
|
179
|
+
if (Option.isNone(raw) || raw.value === "") return {
|
|
180
|
+
spans: void 0,
|
|
181
|
+
invalid: void 0
|
|
182
|
+
};
|
|
183
|
+
const value = raw.value.toLowerCase();
|
|
184
|
+
const spans = SPAN_SETTINGS.find((setting) => setting === value);
|
|
185
|
+
if (spans !== void 0) return {
|
|
186
|
+
spans,
|
|
187
|
+
invalid: void 0
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
spans: void 0,
|
|
191
|
+
invalid: `${envVar}=${raw.value} is not a span setting (${SPAN_SETTINGS.join("|")}); ignoring it`
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
export { FailureTargetCell, currentTarget, fallbackTarget, guardConsumerLines, linesOf, plainFailureLines, readSpans, refreshFailureTarget };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CliError, Prompt } from "effect/cli";
|
|
3
|
+
|
|
4
|
+
// src/internal/fallbackAnswer.ts
|
|
5
|
+
/**
|
|
6
|
+
* What a fallback hands core when the run is not interactive: `otherwise`, answered, when given (`undefined` counts
|
|
7
|
+
* as not given); otherwise core's own missing-parameter error, so the parse fails exactly as it would with no
|
|
8
|
+
* fallback and `CliRuntime.main` exits `64`. Shared by `CliPrompt.fallback` and `CliUi.fallback`.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const answerWithoutPerson = (target) => {
|
|
13
|
+
if ("otherwise" in target && target.otherwise !== void 0) return Effect.succeed(Prompt.succeed(target.otherwise));
|
|
14
|
+
return Effect.fail("flag" in target ? new CliError.MissingOption({ option: target.flag }) : new CliError.MissingArgument({ argument: target.argument }));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { answerWithoutPerson };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { sanitize } from "../Fmt.js";
|
|
2
|
+
import { formatNdjson, passes } from "./diagnostics.js";
|
|
3
|
+
import { Cause, Console, Effect, Exit, Fiber, FileSystem, Logger, Path, Queue } from "effect";
|
|
4
|
+
import { CommandNeutralizer } from "@effected/github-commands";
|
|
5
|
+
|
|
6
|
+
// src/internal/fileSink.ts
|
|
7
|
+
/** How long closing the scope waits for queued lines to reach the file before giving up on a hung filesystem. */
|
|
8
|
+
const CLOSE_TIMEOUT = "2 seconds";
|
|
9
|
+
/**
|
|
10
|
+
* An asynchronous NDJSON file logger.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* `Logger.make` takes a synchronous callback, so the logger only offers the line to a queue; a fiber scoped to the
|
|
14
|
+
* layer drains it and appends each batch with `FileSystem.writeFileString(..., { flag: "a" })`. The first write
|
|
15
|
+
* error prints one stderr line and disables the sink: later lines, including any still queued, are discarded
|
|
16
|
+
* without a message. A defect from the filesystem counts as a write error. Closing the scope ends the queue and
|
|
17
|
+
* waits for the drain for at most two seconds, so lines queued before the close are flushed unless the sink had
|
|
18
|
+
* already disabled itself or the filesystem hangs; past the bound the drain is interrupted and the rest is lost.
|
|
19
|
+
*
|
|
20
|
+
* The stderr line is held to the rules of every other kit write: the path (which a workflow can set through an env
|
|
21
|
+
* var) and the error's message are sanitised, and the line is neutralized when `underActions` says the drain's fiber
|
|
22
|
+
* runs under GitHub Actions, the same decision the diagnostics sink makes.
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
const makeFileSink = (path, installed, underActions) => Effect.gen(function* () {
|
|
27
|
+
const fs = yield* FileSystem.FileSystem;
|
|
28
|
+
const location = yield* Path.Path;
|
|
29
|
+
const queue = yield* Queue.unbounded();
|
|
30
|
+
let disabled = false;
|
|
31
|
+
let directoryMade = false;
|
|
32
|
+
const append = (lines) => Effect.gen(function* () {
|
|
33
|
+
if (!directoryMade) {
|
|
34
|
+
yield* fs.makeDirectory(location.dirname(path), { recursive: true });
|
|
35
|
+
directoryMade = true;
|
|
36
|
+
}
|
|
37
|
+
yield* fs.writeFileString(path, lines.map((line) => `${line}\n`).join(""), { flag: "a" });
|
|
38
|
+
});
|
|
39
|
+
const drain = Effect.gen(function* () {
|
|
40
|
+
while (true) {
|
|
41
|
+
const batch = yield* Queue.takeAll(queue);
|
|
42
|
+
if (disabled) continue;
|
|
43
|
+
const exit = yield* Effect.exit(append(batch));
|
|
44
|
+
if (Exit.isFailure(exit)) {
|
|
45
|
+
disabled = true;
|
|
46
|
+
const error = Cause.squash(exit.cause);
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
const line = sanitize(`diagnostics log file ${path} failed: ${message}; further file logging disabled`);
|
|
49
|
+
yield* Effect.withFiber((self) => Console.error(underActions(self) ? CommandNeutralizer.text(line) : line));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}).pipe(Effect.ignore);
|
|
53
|
+
const fiber = yield* Effect.forkScoped(drain);
|
|
54
|
+
yield* Effect.addFinalizer(() => Queue.end(queue).pipe(Effect.andThen(Fiber.join(fiber).pipe(Effect.timeout(CLOSE_TIMEOUT))), Effect.ignore));
|
|
55
|
+
return Logger.make((record) => {
|
|
56
|
+
if (!passes(record, installed)) return;
|
|
57
|
+
Queue.offerUnsafe(queue, formatNdjson(record));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export { makeFileSink };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
import { SchemaAST, SchemaIssue } from "effect";
|
|
3
|
+
|
|
4
|
+
// src/internal/format.ts
|
|
5
|
+
/** The message an unknown key renders as: the leaf hook below writes it and the "did you mean" hint reads it. */
|
|
6
|
+
const UNKNOWN_KEY = "unknown key";
|
|
7
|
+
/**
|
|
8
|
+
* Core's structured formatter, with one phrasing override.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Built once: it is a pure function of the issue tree and carries no state.
|
|
12
|
+
*
|
|
13
|
+
* Core renders an excess property as `"Expected no excess property"`, which
|
|
14
|
+
* describes the **schema's rule** rather than the **user's mistake**. The path
|
|
15
|
+
* already names the key, so `unknown key at groups.g.cleanup.rulesetz` says the
|
|
16
|
+
* same thing in the words someone editing a config file would use. Every other
|
|
17
|
+
* leaf keeps core's phrasing, which is why this goes through `defaultLeafHook`
|
|
18
|
+
* rather than a table of our own.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
const formatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: (issue) => issue._tag === "UnexpectedKey" ? UNKNOWN_KEY : SchemaIssue.defaultLeafHook(issue) });
|
|
23
|
+
/**
|
|
24
|
+
* Flatten an issue tree to one line per rejected value.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Shared by `SchemaIssueRenderer` and `ConfigIssueRenderer` and imported by
|
|
28
|
+
* nothing else. It lives here rather than in either module so that
|
|
29
|
+
* `ConfigIssueRenderer` — the only export that references the optional
|
|
30
|
+
* `@effected/config-file` peer — can stay a module no other module imports.
|
|
31
|
+
*
|
|
32
|
+
* **No walker of our own.** `makeFormatterStandardSchemaV1` already flattens the
|
|
33
|
+
* tree to `{ message, path }` entries, and `defaultLeafHook` covers every leaf
|
|
34
|
+
* variant, so a wrong *type* renders as sensibly as an unknown key and a
|
|
35
|
+
* variant added upstream is covered without a change here.
|
|
36
|
+
*
|
|
37
|
+
* Nodes are never stringified: each carries the entire AST inline, annotations
|
|
38
|
+
* included, so `String(node)` would dump the schema rather than describe the
|
|
39
|
+
* failure. Only `message` and `path` are read.
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
const formatIssue = (issue) => issueEntries(issue).map((entry) => entry.path.length === 0 ? entry.message : `${entry.message} at ${entry.path.join(".")}`);
|
|
44
|
+
/**
|
|
45
|
+
* {@link formatIssue}, with a "did you mean" hint on an unknown key the schema has a near match for.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* Used by `ConfigIssueRenderer` only. The declared keys come from the issue tree itself: core's `UnexpectedKey` leaf
|
|
49
|
+
* carries the AST of the struct being decoded, whose property signatures are the keys the schema accepts at that
|
|
50
|
+
* path, so no schema is passed in and no `@effected/config-file` value is needed. The hint is `Fmt.suggest`'s list,
|
|
51
|
+
* each key sanitised, and a line with no near match is exactly the line {@link formatIssue} draws.
|
|
52
|
+
*
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
const formatIssueWithHints = (issue) => {
|
|
56
|
+
const declared = declaredKeys(issue);
|
|
57
|
+
return issueEntries(issue).map((entry) => {
|
|
58
|
+
if (entry.path.length === 0) return entry.message;
|
|
59
|
+
const line = `${entry.message} at ${entry.path.join(".")}`;
|
|
60
|
+
if (entry.message !== UNKNOWN_KEY) return line;
|
|
61
|
+
const candidates = declared.get(pathKey(entry.path));
|
|
62
|
+
const input = entry.path[entry.path.length - 1];
|
|
63
|
+
if (candidates === void 0 || input === void 0) return line;
|
|
64
|
+
const near = Fmt.suggest(input, candidates);
|
|
65
|
+
if (near.length === 0) return line;
|
|
66
|
+
return `${line} (did you mean ${orList(near.map((key) => `"${Fmt.sanitize(key)}"`))}?)`;
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
/** A path as a map key: `JSON.stringify` keeps `["a.b"]` and `["a", "b"]` apart, which joining on `.` would not. */
|
|
70
|
+
const pathKey = (path) => JSON.stringify(path);
|
|
71
|
+
/** `"a"`, `"a" or "b"`, `"a", "b" or "c"`. */
|
|
72
|
+
const orList = (items) => items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
73
|
+
/**
|
|
74
|
+
* The keys each unknown key's struct declares, by the unknown key's own path.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* Walks the tree the way core's formatter does (a `Pointer` extends the path; a `Filter`, `Encoding`, `Composite` or
|
|
78
|
+
* `AnyOf` passes it through), so a path here is the path the formatter gives the same leaf. A union reports one
|
|
79
|
+
* `UnexpectedKey` per branch at one path, and every branch's keys are candidates. Only string keys are offered: a
|
|
80
|
+
* symbol key is never something a person typed into a config file.
|
|
81
|
+
*/
|
|
82
|
+
const declaredKeys = (issue) => {
|
|
83
|
+
const found = /* @__PURE__ */ new Map();
|
|
84
|
+
if (!SchemaIssue.isIssue(issue)) return found;
|
|
85
|
+
const walk = (node, path) => {
|
|
86
|
+
switch (node._tag) {
|
|
87
|
+
case "Filter":
|
|
88
|
+
case "Encoding":
|
|
89
|
+
walk(node.issue, path);
|
|
90
|
+
return;
|
|
91
|
+
case "Pointer":
|
|
92
|
+
walk(node.issue, [...path, ...node.path.map(String)]);
|
|
93
|
+
return;
|
|
94
|
+
case "Composite":
|
|
95
|
+
case "AnyOf":
|
|
96
|
+
for (const child of node.issues) walk(child, path);
|
|
97
|
+
return;
|
|
98
|
+
case "UnexpectedKey": {
|
|
99
|
+
if (!SchemaAST.isObjects(node.ast)) return;
|
|
100
|
+
const key = pathKey(path);
|
|
101
|
+
const keys = found.get(key) ?? /* @__PURE__ */ new Set();
|
|
102
|
+
for (const signature of node.ast.propertySignatures) if (typeof signature.name === "string") keys.add(signature.name);
|
|
103
|
+
found.set(key, keys);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
default: return;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
walk(issue, []);
|
|
110
|
+
return found;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The rejected values of an issue tree, once each: what both the lines and the tree are built from.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* Anything that is not an issue tree yields none rather than throwing. A rendering helper on an error path must never
|
|
117
|
+
* become the reason a program dies: it is called when something has already gone wrong. A union reports every branch
|
|
118
|
+
* it tried, so one wrong key in a three-member union is the same entry three times; the per-branch "Missing key"
|
|
119
|
+
* entries differ and are kept, since they say which shapes were allowed, but the repeat is pure noise in front of them.
|
|
120
|
+
*
|
|
121
|
+
* @internal
|
|
122
|
+
*/
|
|
123
|
+
const issueEntries = (issue) => {
|
|
124
|
+
if (!SchemaIssue.isIssue(issue)) return [];
|
|
125
|
+
const seen = /* @__PURE__ */ new Set();
|
|
126
|
+
const entries = [];
|
|
127
|
+
for (const entry of formatter(issue).issues) {
|
|
128
|
+
const path = (entry.path ?? []).map(String);
|
|
129
|
+
const key = path.length === 0 ? entry.message : `${entry.message} at ${path.join(".")}`;
|
|
130
|
+
if (seen.has(key)) continue;
|
|
131
|
+
seen.add(key);
|
|
132
|
+
entries.push({
|
|
133
|
+
message: entry.message,
|
|
134
|
+
path
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return entries;
|
|
138
|
+
};
|
|
139
|
+
const emptyNode = () => ({
|
|
140
|
+
messages: [],
|
|
141
|
+
children: /* @__PURE__ */ new Map()
|
|
142
|
+
});
|
|
143
|
+
/**
|
|
144
|
+
* The entries as the input of a `Doc.tree`: one node per path segment, so `groups.g.extra` is three nested nodes, and
|
|
145
|
+
* the message at the end of a path is the node's label (`extra: unknown key`) or, when the node has more to say, its
|
|
146
|
+
* leaves.
|
|
147
|
+
*
|
|
148
|
+
* @internal
|
|
149
|
+
*/
|
|
150
|
+
const issueTreeChildren = (entries) => {
|
|
151
|
+
const root = emptyNode();
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
let node = root;
|
|
154
|
+
for (const segment of entry.path) {
|
|
155
|
+
let next = node.children.get(segment);
|
|
156
|
+
if (next === void 0) {
|
|
157
|
+
next = emptyNode();
|
|
158
|
+
node.children.set(segment, next);
|
|
159
|
+
}
|
|
160
|
+
node = next;
|
|
161
|
+
}
|
|
162
|
+
node.messages.push(entry.message);
|
|
163
|
+
}
|
|
164
|
+
const leaves = (node) => node.messages.map((message) => ({ label: message }));
|
|
165
|
+
const named = (segment, node) => {
|
|
166
|
+
if (node.children.size === 0 && node.messages.length === 1) return { label: `${segment}: ${node.messages[0]}` };
|
|
167
|
+
return {
|
|
168
|
+
label: segment,
|
|
169
|
+
children: [...leaves(node), ...[...node.children].map(([name, child]) => named(name, child))]
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
return [...leaves(root), ...[...root.children].map(([name, child]) => named(name, child))];
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { formatIssue, formatIssueWithHints, issueEntries, issueTreeChildren };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/internal/inspect.ts
|
|
2
|
+
/** The value's `_tag` when it is a string; `undefined` otherwise, and when reading it throws. */
|
|
3
|
+
const tagOf = (value) => {
|
|
4
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
5
|
+
try {
|
|
6
|
+
const tag = value._tag;
|
|
7
|
+
return typeof tag === "string" ? tag : void 0;
|
|
8
|
+
} catch {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A failure value as report text: `String(value)`, except that a plain object whose `String` says only
|
|
14
|
+
* `[object Object]` is its string `message` when it has one. `String(value)` throws for an object with no prototype or
|
|
15
|
+
* a hostile `toString`: that value is `unprintable`.
|
|
16
|
+
*/
|
|
17
|
+
const failureText = (value, unprintable = "[unprintable value]") => {
|
|
18
|
+
try {
|
|
19
|
+
const text = String(value);
|
|
20
|
+
if (text !== "[object Object]") return text;
|
|
21
|
+
const message = value?.message;
|
|
22
|
+
return typeof message === "string" ? message : text;
|
|
23
|
+
} catch {
|
|
24
|
+
return unprintable;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A property of a failure value, or `undefined` when it has none or reading it throws: a report must never fail on a
|
|
29
|
+
* hostile getter. An `Error`'s `stack` counts, since V8 formats it on first read and the formatting reads `message`.
|
|
30
|
+
*/
|
|
31
|
+
const readProperty = (value, key) => {
|
|
32
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
33
|
+
try {
|
|
34
|
+
return value[key];
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export { failureText, readProperty, tagOf };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/internal/isExitCode.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether `code` is an exit status a POSIX process can carry: an integer in
|
|
4
|
+
* `0..255`. `NaN`, fractions and out-of-range values all fail — a relational
|
|
5
|
+
* check alone would admit `NaN`.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
const isExitCode = (code) => Number.isInteger(code) && code >= 0 && code <= 255;
|
|
10
|
+
|
|
11
|
+
export { isExitCode };
|