@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.
Files changed (103) hide show
  1. package/Cancelled.js +43 -0
  2. package/CliAudience.js +177 -0
  3. package/CliColor.js +59 -0
  4. package/CliEnv.js +101 -0
  5. package/CliExit.js +64 -0
  6. package/CliFailure.js +287 -0
  7. package/CliFormat.js +124 -0
  8. package/CliInteractive.js +94 -0
  9. package/CliLinks.js +153 -0
  10. package/CliLog.js +367 -0
  11. package/CliLogger.js +91 -0
  12. package/CliMessage.js +79 -0
  13. package/CliPrompt.js +110 -0
  14. package/CliRuntime.js +254 -0
  15. package/CliTest.js +206 -0
  16. package/CliTheme.js +142 -0
  17. package/ConfigIssueRenderer.js +63 -0
  18. package/Doc.js +548 -0
  19. package/Fmt.js +335 -0
  20. package/GithubAnnotation.js +39 -0
  21. package/Glyphs.js +82 -0
  22. package/LICENSE +21 -0
  23. package/NotInteractive.js +41 -0
  24. package/README.md +215 -2
  25. package/Render.js +280 -0
  26. package/SchemaIssueRenderer.js +47 -0
  27. package/Status.js +165 -0
  28. package/TestTerminal.js +101 -0
  29. package/Token.js +68 -0
  30. package/index.d.ts +3951 -0
  31. package/index.js +27 -0
  32. package/internal/ExitRequested.js +21 -0
  33. package/internal/HelpRouting.js +35 -0
  34. package/internal/ansi.js +229 -0
  35. package/internal/autoFormat.js +33 -0
  36. package/internal/canPrompt.js +14 -0
  37. package/internal/counts.js +83 -0
  38. package/internal/diagnostics.js +31 -0
  39. package/internal/displayPath.js +46 -0
  40. package/internal/displayWidth.js +42 -0
  41. package/internal/failureTarget.js +195 -0
  42. package/internal/fallbackAnswer.js +17 -0
  43. package/internal/fileSink.js +61 -0
  44. package/internal/format.js +175 -0
  45. package/internal/inspect.js +40 -0
  46. package/internal/isExitCode.js +11 -0
  47. package/internal/layout.js +249 -0
  48. package/internal/linkScheme.js +29 -0
  49. package/internal/linkTarget.js +49 -0
  50. package/internal/logSafety.js +45 -0
  51. package/internal/renderAnsi.js +51 -0
  52. package/internal/renderDoc.js +322 -0
  53. package/internal/renderGithubLog.js +59 -0
  54. package/internal/renderMarkdown.js +367 -0
  55. package/internal/renderPlain.js +49 -0
  56. package/internal/scanAudience.js +105 -0
  57. package/internal/splitFrame.js +55 -0
  58. package/internal/terminalCapabilities.js +23 -0
  59. package/internal/wizardGate.js +17 -0
  60. package/package.json +90 -5
  61. package/testing.d.ts +246 -0
  62. package/testing.js +4 -0
  63. package/tsdoc-metadata.json +11 -0
  64. package/ui/CliUi.js +507 -0
  65. package/ui/CliUiDetached.js +36 -0
  66. package/ui/CliUiLive.js +436 -0
  67. package/ui/CliUiRouteStream.js +72 -0
  68. package/ui/Confirm.js +244 -0
  69. package/ui/DocView.js +76 -0
  70. package/ui/KeyHelp.js +61 -0
  71. package/ui/KeyTable.js +198 -0
  72. package/ui/MultiSelect.js +259 -0
  73. package/ui/Select.js +229 -0
  74. package/ui/Tabs.js +201 -0
  75. package/ui/TextInput.js +289 -0
  76. package/ui/Toggle.js +31 -0
  77. package/ui/UiKey.js +48 -0
  78. package/ui/UiProvider.js +62 -0
  79. package/ui/UiStreams.js +17 -0
  80. package/ui/UiTheme.js +147 -0
  81. package/ui/Viewport.js +203 -0
  82. package/ui/internal/ErrorBoundary.js +29 -0
  83. package/ui/internal/Holder.js +73 -0
  84. package/ui/internal/ScreenContext.js +51 -0
  85. package/ui/internal/UiProviders.js +38 -0
  86. package/ui/internal/ink.js +121 -0
  87. package/ui/internal/inkChalk.js +47 -0
  88. package/ui/internal/inkConsole.js +150 -0
  89. package/ui/internal/lazyView.js +73 -0
  90. package/ui/internal/lineRouter.js +70 -0
  91. package/ui/internal/lineText.js +18 -0
  92. package/ui/internal/mountPermit.js +15 -0
  93. package/ui/internal/processStreams.js +18 -0
  94. package/ui/internal/renderOptions.js +12 -0
  95. package/ui/testing/CliUiTest.js +792 -0
  96. package/ui/testing/fakeStreams.js +104 -0
  97. package/ui/testing/terminalModel.js +67 -0
  98. package/ui-testing-serializer.d.ts +12 -0
  99. package/ui-testing-serializer.js +32 -0
  100. package/ui-testing.d.ts +558 -0
  101. package/ui-testing.js +3 -0
  102. package/ui.d.ts +1990 -0
  103. 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 };