@checkstack/ui 1.11.0 → 1.12.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/.storybook/main.ts +43 -0
- package/CHANGELOG.md +181 -0
- package/package.json +4 -4
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/ActionCard.tsx +96 -8
- package/src/components/CodeEditor/CodeEditor.tsx +95 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +279 -123
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +24 -0
- package/src/components/CodeEditor/monacoTsService.ts +217 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +59 -0
- package/src/components/CodeEditor/validateScripts.ts +132 -0
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +25 -1
- package/src/components/DynamicForm/FormField.tsx +109 -1
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +67 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +6 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +72 -1
- package/src/components/DynamicForm/utils.ts +32 -0
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +4 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Shared standalone TypeScript / JavaScript language-service setup.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from `TypefoxEditor` so it can be imported by BOTH the editor and
|
|
4
|
+
// the headless `validateScripts` validator. Both need the same singletons
|
|
5
|
+
// configured (compiler options, ambient stdlib types, the TS worker), and the
|
|
6
|
+
// validator must work even when NO editor is mounted (every script action card
|
|
7
|
+
// collapsed) - so the setup cannot live inside the editor component module's
|
|
8
|
+
// render path.
|
|
9
|
+
//
|
|
10
|
+
// The TS/JS language-service `defaults` are singletons, so the configuration
|
|
11
|
+
// here runs ONCE at module load. The worker factory is registered lazily (and
|
|
12
|
+
// idempotently) via `ensureStandaloneWorkerFactory()` because the editor also
|
|
13
|
+
// registers it during its own init - the guard makes a double-call a no-op.
|
|
14
|
+
|
|
15
|
+
// Side-effect import: registers the standalone language grammars. Imported here
|
|
16
|
+
// too (not only in TypefoxEditor) so the validator gets a fully-registered TS
|
|
17
|
+
// language environment regardless of import order.
|
|
18
|
+
import "@codingame/monaco-vscode-standalone-languages";
|
|
19
|
+
// The named imports below ALSO trigger this package's side-effect registration
|
|
20
|
+
// of the standalone TypeScript language features (defaults + ts.worker).
|
|
21
|
+
import {
|
|
22
|
+
typescriptDefaults,
|
|
23
|
+
javascriptDefaults,
|
|
24
|
+
ScriptTarget,
|
|
25
|
+
ModuleKind,
|
|
26
|
+
ModuleResolutionKind,
|
|
27
|
+
} from "@codingame/monaco-vscode-standalone-typescript-language-features";
|
|
28
|
+
// Worker entry URLs, bundled and resolved by Vite via the `?worker&url` suffix.
|
|
29
|
+
// Imported as URL STRINGS (not Worker constructors) because
|
|
30
|
+
// monaco-languageclient's worker factory consumes `loader().url.toString()`.
|
|
31
|
+
import editorWorkerUrl from "@codingame/monaco-vscode-editor-api/esm/vs/editor/editor.worker.js?worker&url";
|
|
32
|
+
import tsWorkerUrl from "@codingame/monaco-vscode-standalone-typescript-language-features/worker?worker&url";
|
|
33
|
+
import jsonWorkerUrl from "@codingame/monaco-vscode-standalone-json-language-features/worker?worker&url";
|
|
34
|
+
import {
|
|
35
|
+
// `useWorkerFactory` is a plain library registration function, not a React
|
|
36
|
+
// hook. We alias away the `use` prefix so the `react-hooks/rules-of-hooks`
|
|
37
|
+
// lint rule (which keys purely off the identifier name) does not misfire.
|
|
38
|
+
useWorkerFactory as registerWorkerFactory,
|
|
39
|
+
Worker,
|
|
40
|
+
type WorkerFactoryConfig,
|
|
41
|
+
type WorkerLoader,
|
|
42
|
+
} from "monaco-languageclient/workerFactory";
|
|
43
|
+
|
|
44
|
+
// Re-exported so consumers keep a single import surface for the TS services.
|
|
45
|
+
export {
|
|
46
|
+
typescriptDefaults,
|
|
47
|
+
javascriptDefaults,
|
|
48
|
+
ScriptTarget,
|
|
49
|
+
ModuleKind,
|
|
50
|
+
ModuleResolutionKind,
|
|
51
|
+
} from "@codingame/monaco-vscode-standalone-typescript-language-features";
|
|
52
|
+
|
|
53
|
+
// The logger type originates from `@codingame/monaco-vscode-log-service-override`,
|
|
54
|
+
// which is not a direct dependency of this package. We derive it from the
|
|
55
|
+
// `WorkerFactoryConfig` we already import so we never reach for a transitive
|
|
56
|
+
// specifier (and never need an `any`).
|
|
57
|
+
type WorkerFactoryLogger = WorkerFactoryConfig["logger"];
|
|
58
|
+
|
|
59
|
+
const editorWorkerLoader: WorkerLoader = () =>
|
|
60
|
+
new Worker(editorWorkerUrl, { type: "module" });
|
|
61
|
+
|
|
62
|
+
const tsWorkerLoader: WorkerLoader = () =>
|
|
63
|
+
new Worker(tsWorkerUrl, { type: "module" });
|
|
64
|
+
|
|
65
|
+
const jsonWorkerLoader: WorkerLoader = () =>
|
|
66
|
+
new Worker(jsonWorkerUrl, { type: "module" });
|
|
67
|
+
|
|
68
|
+
// The monaco-vscode API initializes globally exactly ONCE, and that init is
|
|
69
|
+
// owned by the editor wrapper (`MonacoEditorReactComp`) - it throws "Services
|
|
70
|
+
// are already initialized" if anything else inits first. So the headless
|
|
71
|
+
// validator must NOT touch the worker / models until an editor has brought the
|
|
72
|
+
// services up. The editor flips this flag from its `onEditorStartDone`; the
|
|
73
|
+
// validator checks it and otherwise no-ops. Net effect: scripts validate once
|
|
74
|
+
// any script editor has been opened this session (covering collapsed cards
|
|
75
|
+
// from then on); a never-opened, all-collapsed automation is left to the
|
|
76
|
+
// deferred backend typecheck.
|
|
77
|
+
let vscodeServicesReady = false;
|
|
78
|
+
const servicesReadyListeners = new Set<() => void>();
|
|
79
|
+
|
|
80
|
+
/** Called by the editor once the monaco-vscode services have initialized. */
|
|
81
|
+
export const markVscodeServicesReady = (): void => {
|
|
82
|
+
if (vscodeServicesReady) return;
|
|
83
|
+
vscodeServicesReady = true;
|
|
84
|
+
for (const listener of servicesReadyListeners) listener();
|
|
85
|
+
servicesReadyListeners.clear();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** True once an editor has initialized the monaco-vscode services. */
|
|
89
|
+
export const areVscodeServicesReady = (): boolean => vscodeServicesReady;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Subscribe to the one-time "services ready" transition. Fires immediately if
|
|
93
|
+
* already ready. Returns an unsubscribe. Lets the headless validator re-run the
|
|
94
|
+
* moment the first editor brings the services up (otherwise a never-edited
|
|
95
|
+
* definition would not re-validate just because a card was opened).
|
|
96
|
+
*/
|
|
97
|
+
export const onVscodeServicesReady = (listener: () => void): (() => void) => {
|
|
98
|
+
if (vscodeServicesReady) {
|
|
99
|
+
listener();
|
|
100
|
+
return () => {};
|
|
101
|
+
}
|
|
102
|
+
servicesReadyListeners.add(listener);
|
|
103
|
+
return () => servicesReadyListeners.delete(listener);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let workerFactoryRegistered = false;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Register the worker loaders for the standalone (classic) Monaco setup. We
|
|
110
|
+
* only need the generic editor worker plus the TypeScript worker (which also
|
|
111
|
+
* serves JavaScript) and the JSON worker. Mirrors the upstream
|
|
112
|
+
* `defineClassicWorkers` helper.
|
|
113
|
+
*
|
|
114
|
+
* Idempotent: the first caller registers, subsequent calls no-op. Both the
|
|
115
|
+
* editor (via its `monacoWorkerFactory` app-config hook) AND the headless
|
|
116
|
+
* validator call this, so the guard prevents a double registration. The
|
|
117
|
+
* editor's call may pass a `logger`; the first registration wins, so when the
|
|
118
|
+
* validator registers first the editor simply reuses it (logging is optional).
|
|
119
|
+
*/
|
|
120
|
+
export const ensureStandaloneWorkerFactory = (
|
|
121
|
+
logger?: WorkerFactoryLogger,
|
|
122
|
+
): void => {
|
|
123
|
+
if (workerFactoryRegistered) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
workerFactoryRegistered = true;
|
|
127
|
+
registerWorkerFactory({
|
|
128
|
+
workerLoaders: {
|
|
129
|
+
editorWorkerService: editorWorkerLoader,
|
|
130
|
+
// Both must be defined or the worker factory errors (see upstream
|
|
131
|
+
// helper-classic.ts).
|
|
132
|
+
javascript: tsWorkerLoader,
|
|
133
|
+
typescript: tsWorkerLoader,
|
|
134
|
+
json: jsonWorkerLoader,
|
|
135
|
+
},
|
|
136
|
+
logger,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Base compiler options for the standalone TS + JS services. `types` (node +
|
|
141
|
+
// bun-types) is added only once the stdlib bundle has loaded (see
|
|
142
|
+
// ensureStandaloneStdlib), so the service doesn't transiently error on a
|
|
143
|
+
// missing `node` type while the ~3 MB bundle is still fetching.
|
|
144
|
+
//
|
|
145
|
+
// `baseUrl: "file:///"` anchors NodeJs bare-import resolution at the virtual
|
|
146
|
+
// root so an `import "lodash"` walks `file:///node_modules/...` - the same
|
|
147
|
+
// virtual layout the stdlib bundle AND the lazy-ATA extra-libs are registered
|
|
148
|
+
// under. `typeRoots` lists BOTH the `@types` root (so a package with no own
|
|
149
|
+
// types, e.g. lodash, falls back to `@types/lodash`) and the bare
|
|
150
|
+
// `node_modules` root (so the bundled `bun-types`, which is NOT under
|
|
151
|
+
// `@types`, still resolves as an ambient `types` entry).
|
|
152
|
+
export const BASE_COMPILER_OPTIONS = {
|
|
153
|
+
target: ScriptTarget.ESNext,
|
|
154
|
+
module: ModuleKind.ESNext,
|
|
155
|
+
moduleResolution: ModuleResolutionKind.NodeJs,
|
|
156
|
+
lib: ["esnext"],
|
|
157
|
+
allowNonTsExtensions: false,
|
|
158
|
+
noEmit: true,
|
|
159
|
+
strict: true,
|
|
160
|
+
esModuleInterop: true,
|
|
161
|
+
baseUrl: "file:///",
|
|
162
|
+
typeRoots: ["file:///node_modules/@types", "file:///node_modules"],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Configure the standalone TS + JS language services ONCE at module load.
|
|
167
|
+
* `typescriptDefaults` / `javascriptDefaults` are singletons, so doing this at
|
|
168
|
+
* module scope (not per-mount) guarantees the first editor to mount cannot
|
|
169
|
+
* start the service with stale defaults - the timing race the legacy monaco
|
|
170
|
+
* editor hit.
|
|
171
|
+
*/
|
|
172
|
+
const configureTypeScriptDefaults = (): void => {
|
|
173
|
+
for (const defaults of [typescriptDefaults, javascriptDefaults]) {
|
|
174
|
+
defaults.setCompilerOptions({ ...BASE_COMPILER_OPTIONS });
|
|
175
|
+
// 1108: a top-level `return` is valid because the runtime wraps scripts in
|
|
176
|
+
// an async IIFE (same suppression as the legacy editor).
|
|
177
|
+
defaults.setDiagnosticsOptions({ diagnosticCodesToIgnore: [1108] });
|
|
178
|
+
// Push models to the worker eagerly so diagnostics/completions are ready on
|
|
179
|
+
// the first keystroke.
|
|
180
|
+
defaults.setEagerModelSync(true);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
configureTypeScriptDefaults();
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Lazy-load the bundled `@types/node` + `bun-types` declarations into the
|
|
188
|
+
* standalone TS service so script editors have `console`, `fetch`, `process`,
|
|
189
|
+
* `Bun`, etc. typed. The ~3 MB bundle is code-split into its own chunk and
|
|
190
|
+
* fetched once. Runs at module load; this file is browser-only so the dynamic
|
|
191
|
+
* import is safe here. Returns the in-flight promise so callers (the headless
|
|
192
|
+
* validator) can await stdlib readiness before requesting diagnostics.
|
|
193
|
+
*/
|
|
194
|
+
let stdlibLoad: Promise<void> | undefined;
|
|
195
|
+
export const ensureStandaloneStdlib = (): Promise<void> => {
|
|
196
|
+
if (stdlibLoad) {
|
|
197
|
+
return stdlibLoad;
|
|
198
|
+
}
|
|
199
|
+
stdlibLoad = (async () => {
|
|
200
|
+
const stdlibModule = await import("./generated/stdlib-types.json");
|
|
201
|
+
const bundle = stdlibModule.default;
|
|
202
|
+
for (const defaults of [typescriptDefaults, javascriptDefaults]) {
|
|
203
|
+
for (const [path, content] of Object.entries(bundle)) {
|
|
204
|
+
defaults.addExtraLib(content, `file:///${path}`);
|
|
205
|
+
}
|
|
206
|
+
// The @types/node + bun-types declarations now exist at their node_modules
|
|
207
|
+
// virtual paths, so include them ambiently.
|
|
208
|
+
defaults.setCompilerOptions({
|
|
209
|
+
...BASE_COMPILER_OPTIONS,
|
|
210
|
+
types: ["node", "bun-types"],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
return stdlibLoad;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
void ensureStandaloneStdlib();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { popoutTitle } from "./popoutTitle";
|
|
3
|
+
import type { CodeEditorLanguage } from "./types";
|
|
4
|
+
|
|
5
|
+
describe("popoutTitle", () => {
|
|
6
|
+
it("derives a script title for TypeScript", () => {
|
|
7
|
+
expect(popoutTitle({ language: "typescript" })).toBe(
|
|
8
|
+
"Edit script - TypeScript",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("derives a script title for JavaScript", () => {
|
|
13
|
+
expect(popoutTitle({ language: "javascript" })).toBe(
|
|
14
|
+
"Edit script - JavaScript",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("derives a script title for Shell", () => {
|
|
19
|
+
expect(popoutTitle({ language: "shell" })).toBe("Edit script - Shell");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("falls back to a generic title for markup/text languages", () => {
|
|
23
|
+
const markupLanguages: CodeEditorLanguage[] = [
|
|
24
|
+
"json",
|
|
25
|
+
"yaml",
|
|
26
|
+
"xml",
|
|
27
|
+
"markdown",
|
|
28
|
+
];
|
|
29
|
+
for (const language of markupLanguages) {
|
|
30
|
+
expect(popoutTitle({ language })).toBe("Edit");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("uses a normal hyphen, never an em-dash", () => {
|
|
35
|
+
expect(popoutTitle({ language: "typescript" })).not.toContain("—");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pure helper deriving the popout-dialog title from the editor language.
|
|
2
|
+
// Kept separate (and unit-tested) so the title logic isn't buried in UI glue.
|
|
3
|
+
import type { CodeEditorLanguage } from "./types";
|
|
4
|
+
|
|
5
|
+
// Human-readable display name for the languages that get a script-flavoured
|
|
6
|
+
// title. Markup/text languages fall through to the generic "Edit" title since
|
|
7
|
+
// "script" wouldn't read correctly for them.
|
|
8
|
+
const SCRIPT_LANGUAGE_LABELS: Partial<Record<CodeEditorLanguage, string>> = {
|
|
9
|
+
typescript: "TypeScript",
|
|
10
|
+
javascript: "JavaScript",
|
|
11
|
+
shell: "Shell",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive the overlay dialog title from the editor language.
|
|
16
|
+
*
|
|
17
|
+
* - Script languages (`typescript` / `javascript` / `shell`) read as
|
|
18
|
+
* `Edit script - <Language>`.
|
|
19
|
+
* - Everything else (markup/text: json / yaml / xml / markdown) falls back to
|
|
20
|
+
* the generic `Edit`.
|
|
21
|
+
*
|
|
22
|
+
* Uses a normal hyphen (not an em-dash) per the project content style.
|
|
23
|
+
*/
|
|
24
|
+
export const popoutTitle = ({
|
|
25
|
+
language,
|
|
26
|
+
}: {
|
|
27
|
+
language: CodeEditorLanguage;
|
|
28
|
+
}): string => {
|
|
29
|
+
const scriptLabel = SCRIPT_LANGUAGE_LABELS[language];
|
|
30
|
+
return scriptLabel ? `Edit script - ${scriptLabel}` : "Edit";
|
|
31
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildValidationSource,
|
|
4
|
+
flattenDiagnosticMessage,
|
|
5
|
+
mapWorkerDiagnostics,
|
|
6
|
+
offsetToPosition,
|
|
7
|
+
type RawTsDiagnostic,
|
|
8
|
+
} from "./scriptDiagnostics";
|
|
9
|
+
|
|
10
|
+
describe("offsetToPosition", () => {
|
|
11
|
+
it("returns 1-based line/column", () => {
|
|
12
|
+
const text = "ab\ncde\nf";
|
|
13
|
+
expect(offsetToPosition(text, 0)).toEqual({ line: 1, column: 1 });
|
|
14
|
+
expect(offsetToPosition(text, 1)).toEqual({ line: 1, column: 2 });
|
|
15
|
+
// offset 3 is the 'c' (first char after the first newline)
|
|
16
|
+
expect(offsetToPosition(text, 3)).toEqual({ line: 2, column: 1 });
|
|
17
|
+
expect(offsetToPosition(text, 7)).toEqual({ line: 3, column: 1 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("clamps an out-of-range offset to the text length", () => {
|
|
21
|
+
expect(offsetToPosition("ab", 999)).toEqual({ line: 1, column: 3 });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("flattenDiagnosticMessage", () => {
|
|
26
|
+
it("passes through a plain string", () => {
|
|
27
|
+
expect(flattenDiagnosticMessage("boom")).toBe("boom");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("flattens a nested chain depth-first", () => {
|
|
31
|
+
expect(
|
|
32
|
+
flattenDiagnosticMessage({
|
|
33
|
+
messageText: "top",
|
|
34
|
+
next: [
|
|
35
|
+
{ messageText: "child-a" },
|
|
36
|
+
{ messageText: "child-b", next: [{ messageText: "grandchild" }] },
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
).toBe("top child-a child-b grandchild");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("buildValidationSource", () => {
|
|
44
|
+
it("prepends the type defs and reports the prefix line count", () => {
|
|
45
|
+
const { text, prependedLineCount } = buildValidationSource({
|
|
46
|
+
typeDefinitions: "declare const context: { x: number };", // 1 line
|
|
47
|
+
source: "context.x;",
|
|
48
|
+
});
|
|
49
|
+
expect(prependedLineCount).toBe(1);
|
|
50
|
+
expect(text).toBe("declare const context: { x: number };\ncontext.x;");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("counts multi-line type defs", () => {
|
|
54
|
+
const { prependedLineCount } = buildValidationSource({
|
|
55
|
+
typeDefinitions: "line1\nline2\nline3",
|
|
56
|
+
source: "x",
|
|
57
|
+
});
|
|
58
|
+
expect(prependedLineCount).toBe(3);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("mapWorkerDiagnostics", () => {
|
|
63
|
+
// 2 lines of type defs prepended; user source starts at combined line 3.
|
|
64
|
+
const { text, prependedLineCount } = buildValidationSource({
|
|
65
|
+
typeDefinitions: "declare const context: {\n readonly a: number;\n};",
|
|
66
|
+
source: "const z = context.b;\n", // `b` doesn't exist -> error on user line 1
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const errorOffset = text.indexOf("context.b") + "context.".length; // points at `b`
|
|
70
|
+
|
|
71
|
+
it("shifts a real type error back onto the user's source line", () => {
|
|
72
|
+
const diagnostics: RawTsDiagnostic[] = [
|
|
73
|
+
{
|
|
74
|
+
start: errorOffset,
|
|
75
|
+
length: 1,
|
|
76
|
+
category: 1, // Error
|
|
77
|
+
code: 2339,
|
|
78
|
+
messageText: "Property 'b' does not exist on type",
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
const mapped = mapWorkerDiagnostics({
|
|
82
|
+
diagnostics,
|
|
83
|
+
validationText: text,
|
|
84
|
+
prependedLineCount,
|
|
85
|
+
});
|
|
86
|
+
expect(mapped).toHaveLength(1);
|
|
87
|
+
expect(mapped[0]).toMatchObject({
|
|
88
|
+
severity: "error",
|
|
89
|
+
line: 1,
|
|
90
|
+
message: "Property 'b' does not exist on type",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("drops ignored codes (lazy-ATA module resolution, top-level return)", () => {
|
|
95
|
+
const diagnostics: RawTsDiagnostic[] = [
|
|
96
|
+
{ start: errorOffset, category: 1, code: 2307, messageText: "Cannot find module 'lodash'" },
|
|
97
|
+
{ start: errorOffset, category: 1, code: 1108, messageText: "A 'return' statement" },
|
|
98
|
+
];
|
|
99
|
+
expect(
|
|
100
|
+
mapWorkerDiagnostics({ diagnostics, validationText: text, prependedLineCount }),
|
|
101
|
+
).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("drops diagnostics that land inside the prepended type-def prefix", () => {
|
|
105
|
+
const diagnostics: RawTsDiagnostic[] = [
|
|
106
|
+
{ start: 0, category: 1, code: 2300, messageText: "noise in generated types" },
|
|
107
|
+
];
|
|
108
|
+
expect(
|
|
109
|
+
mapWorkerDiagnostics({ diagnostics, validationText: text, prependedLineCount }),
|
|
110
|
+
).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("drops non-error/-warning categories and unpositioned diagnostics", () => {
|
|
114
|
+
const diagnostics: RawTsDiagnostic[] = [
|
|
115
|
+
{ start: errorOffset, category: 2, code: 9999, messageText: "suggestion" },
|
|
116
|
+
{ category: 1, code: 2339, messageText: "global, no position" },
|
|
117
|
+
];
|
|
118
|
+
expect(
|
|
119
|
+
mapWorkerDiagnostics({ diagnostics, validationText: text, prependedLineCount }),
|
|
120
|
+
).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps warnings", () => {
|
|
124
|
+
const diagnostics: RawTsDiagnostic[] = [
|
|
125
|
+
{ start: errorOffset, category: 0, code: 6133, messageText: "'z' is declared but never read" },
|
|
126
|
+
];
|
|
127
|
+
const mapped = mapWorkerDiagnostics({
|
|
128
|
+
diagnostics,
|
|
129
|
+
validationText: text,
|
|
130
|
+
prependedLineCount,
|
|
131
|
+
});
|
|
132
|
+
expect(mapped).toHaveLength(1);
|
|
133
|
+
expect(mapped[0]?.severity).toBe("warning");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Pure helpers for the headless script validator (`validateScripts.ts`).
|
|
2
|
+
//
|
|
3
|
+
// Kept free of any `monaco` / browser import so this logic is unit-testable
|
|
4
|
+
// under bun. The browser-only worker glue lives in `validateScripts.ts` and
|
|
5
|
+
// composes these helpers.
|
|
6
|
+
//
|
|
7
|
+
// Strategy: to type-check a user script against its generated `context.d.ts`
|
|
8
|
+
// WITHOUT polluting the shared TS service's global scope (which would collide
|
|
9
|
+
// with any mounted editor's own `declare const context`), we PREPEND the
|
|
10
|
+
// generated type declarations onto the user's source and validate that single
|
|
11
|
+
// combined file. Inside one module/script file the prepended `declare const
|
|
12
|
+
// context` is in scope for the user's code below it, but it never leaks to
|
|
13
|
+
// other files. Diagnostics that land in the prepended region are dropped, and
|
|
14
|
+
// the rest are shifted back by the number of prepended lines so positions map
|
|
15
|
+
// onto the user's original source.
|
|
16
|
+
|
|
17
|
+
/** A type error/warning located in the user's original (un-prepended) source. */
|
|
18
|
+
export interface ScriptDiagnostic {
|
|
19
|
+
severity: "error" | "warning";
|
|
20
|
+
/** Flattened, human-readable message. */
|
|
21
|
+
message: string;
|
|
22
|
+
/** 1-based line in the user's source. */
|
|
23
|
+
line: number;
|
|
24
|
+
/** 1-based column. */
|
|
25
|
+
column: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Diagnostic codes ignored during headless validation because they reflect the
|
|
30
|
+
* sandbox/loading model rather than a real mistake in the user's logic:
|
|
31
|
+
*
|
|
32
|
+
* - 1108: a top-level `return` is legal (the runtime wraps scripts in an async
|
|
33
|
+
* IIFE) - same suppression the editor applies.
|
|
34
|
+
* - 2307 / 2792: "cannot find module 'x'". Type acquisition (ATA) is lazy and
|
|
35
|
+
* only runs for the editor that is open, so a collapsed-card script's
|
|
36
|
+
* imports have no fetched types yet. Flagging these would be a false
|
|
37
|
+
* positive, so module-resolution failures are not surfaced here (the
|
|
38
|
+
* backend typecheck - deferred - is the place to enforce imports).
|
|
39
|
+
* - 7016: "could not find a declaration file for module 'x'" - same reason.
|
|
40
|
+
*/
|
|
41
|
+
export const IGNORED_DIAGNOSTIC_CODES: ReadonlySet<number> = new Set([
|
|
42
|
+
1108, 2307, 2792, 7016,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// `ts.DiagnosticCategory`: Warning = 0, Error = 1, Suggestion = 2, Message = 3.
|
|
46
|
+
const CATEGORY_ERROR = 1;
|
|
47
|
+
const CATEGORY_WARNING = 0;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Minimal shape of a TypeScript worker diagnostic (subset of monaco's
|
|
51
|
+
* `Diagnostic`). `messageText` is either a string or a nested chain.
|
|
52
|
+
*/
|
|
53
|
+
export interface RawTsDiagnostic {
|
|
54
|
+
start?: number;
|
|
55
|
+
length?: number;
|
|
56
|
+
messageText: string | DiagnosticMessageChain;
|
|
57
|
+
category: number;
|
|
58
|
+
code: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface DiagnosticMessageChain {
|
|
62
|
+
messageText: string;
|
|
63
|
+
next?: DiagnosticMessageChain[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Flatten a (possibly nested) diagnostic message chain to a single string. */
|
|
67
|
+
export function flattenDiagnosticMessage(
|
|
68
|
+
messageText: string | DiagnosticMessageChain,
|
|
69
|
+
): string {
|
|
70
|
+
if (typeof messageText === "string") {
|
|
71
|
+
return messageText;
|
|
72
|
+
}
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
const walk = (chain: DiagnosticMessageChain): void => {
|
|
75
|
+
parts.push(chain.messageText);
|
|
76
|
+
for (const child of chain.next ?? []) {
|
|
77
|
+
walk(child);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(messageText);
|
|
81
|
+
return parts.join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Convert a 0-based character offset into a 1-based {line, column}. */
|
|
85
|
+
export function offsetToPosition(
|
|
86
|
+
text: string,
|
|
87
|
+
offset: number,
|
|
88
|
+
): { line: number; column: number } {
|
|
89
|
+
let line = 1;
|
|
90
|
+
let column = 1;
|
|
91
|
+
const end = Math.min(offset, text.length);
|
|
92
|
+
for (let i = 0; i < end; i++) {
|
|
93
|
+
if (text[i] === "\n") {
|
|
94
|
+
line += 1;
|
|
95
|
+
column = 1;
|
|
96
|
+
} else {
|
|
97
|
+
column += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { line, column };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the combined source to validate: the generated `typeDefinitions`
|
|
105
|
+
* (the `declare const context` + any ambient augmentations) prepended to the
|
|
106
|
+
* user's source. Returns the combined text plus the number of lines the prefix
|
|
107
|
+
* occupies, so diagnostics can be shifted back onto the user's source.
|
|
108
|
+
*/
|
|
109
|
+
export function buildValidationSource({
|
|
110
|
+
typeDefinitions,
|
|
111
|
+
source,
|
|
112
|
+
}: {
|
|
113
|
+
typeDefinitions: string;
|
|
114
|
+
source: string;
|
|
115
|
+
}): { text: string; prependedLineCount: number } {
|
|
116
|
+
// The prefix occupies one line per line of `typeDefinitions`; the trailing
|
|
117
|
+
// "\n" we add then places the user's source on the next line. So the user's
|
|
118
|
+
// line N maps to combined line N + prependedLineCount.
|
|
119
|
+
const prependedLineCount = typeDefinitions.split("\n").length;
|
|
120
|
+
return {
|
|
121
|
+
text: `${typeDefinitions}\n${source}`,
|
|
122
|
+
prependedLineCount,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Map raw worker diagnostics onto the user's source: drop ignored codes,
|
|
128
|
+
* non-error/-warning categories, unpositioned (global) diagnostics, and any
|
|
129
|
+
* that fall inside the prepended type-definition prefix; shift the rest back.
|
|
130
|
+
*/
|
|
131
|
+
export function mapWorkerDiagnostics({
|
|
132
|
+
diagnostics,
|
|
133
|
+
validationText,
|
|
134
|
+
prependedLineCount,
|
|
135
|
+
}: {
|
|
136
|
+
diagnostics: RawTsDiagnostic[];
|
|
137
|
+
validationText: string;
|
|
138
|
+
prependedLineCount: number;
|
|
139
|
+
}): ScriptDiagnostic[] {
|
|
140
|
+
const result: ScriptDiagnostic[] = [];
|
|
141
|
+
for (const diagnostic of diagnostics) {
|
|
142
|
+
if (IGNORED_DIAGNOSTIC_CODES.has(diagnostic.code)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const severity =
|
|
146
|
+
diagnostic.category === CATEGORY_ERROR
|
|
147
|
+
? "error"
|
|
148
|
+
: diagnostic.category === CATEGORY_WARNING
|
|
149
|
+
? "warning"
|
|
150
|
+
: undefined;
|
|
151
|
+
if (severity === undefined) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (diagnostic.start === undefined) {
|
|
155
|
+
// No position - a whole-file diagnostic. Not attributable to a user line,
|
|
156
|
+
// and the inline strategy shouldn't produce these; skip.
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const position = offsetToPosition(validationText, diagnostic.start);
|
|
160
|
+
if (position.line <= prependedLineCount) {
|
|
161
|
+
// Lands in the generated prefix, not the user's code.
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
result.push({
|
|
165
|
+
severity,
|
|
166
|
+
message: flattenDiagnosticMessage(diagnostic.messageText),
|
|
167
|
+
line: position.line - prependedLineCount,
|
|
168
|
+
column: position.column,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
@@ -52,6 +52,30 @@ export interface ShellEnvVar {
|
|
|
52
52
|
example?: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** One declaration file returned by an `AcquireTypes` resolver. */
|
|
56
|
+
export interface AcquiredTypeFile {
|
|
57
|
+
/**
|
|
58
|
+
* Real `node_modules/...`-relative path (e.g.
|
|
59
|
+
* `node_modules/@types/lodash/index.d.ts`). Registered at `file:///<path>`
|
|
60
|
+
* so TypeScript's NodeJs + `@types` resolution can find it.
|
|
61
|
+
*/
|
|
62
|
+
path: string;
|
|
63
|
+
/** Verbatim declaration content (UNWRAPPED — no `declare module` envelope). */
|
|
64
|
+
content: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolver for lazy Automatic Type Acquisition (ATA). Given a bare package
|
|
69
|
+
* specifier (e.g. `lodash`), returns the declaration-file closure to register
|
|
70
|
+
* with the TypeScript service (own types and/or the `@types/*` companion).
|
|
71
|
+
* Returns an empty array when the package has no acquirable types. Plugin-
|
|
72
|
+
* agnostic: the concrete fetch (route URL + lockfile hash + auth) is injected
|
|
73
|
+
* by the consumer (see `@checkstack/script-packages-frontend`).
|
|
74
|
+
*/
|
|
75
|
+
export type AcquireTypes = (
|
|
76
|
+
specifier: string,
|
|
77
|
+
) => Promise<AcquiredTypeFile[]>;
|
|
78
|
+
|
|
55
79
|
/**
|
|
56
80
|
* An externally-supplied diagnostic to render as an inline squiggle. Positions
|
|
57
81
|
* are 1-based line/column (the editor's convention). Callers compute these from
|
|
@@ -106,4 +130,39 @@ export interface CodeEditorProps {
|
|
|
106
130
|
* diagnostics).
|
|
107
131
|
*/
|
|
108
132
|
markers?: EditorMarker[];
|
|
133
|
+
/**
|
|
134
|
+
* Lazy Automatic Type Acquisition resolver. When provided (TS/JS editors),
|
|
135
|
+
* the editor parses bare `import`/`require` specifiers from the buffer and
|
|
136
|
+
* calls this for each NEW package, registering the returned declaration
|
|
137
|
+
* files so `import { x } from "pkg"` autocompletes. Injected by the
|
|
138
|
+
* consumer so `@checkstack/ui` stays plugin-agnostic.
|
|
139
|
+
*/
|
|
140
|
+
acquireTypes?: AcquireTypes;
|
|
141
|
+
/**
|
|
142
|
+
* Identity of the current package install (the lockfile hash). When it
|
|
143
|
+
* changes (a new install), the editor resets its acquired-set so types
|
|
144
|
+
* refresh against the new install.
|
|
145
|
+
*/
|
|
146
|
+
acquireResetKey?: string;
|
|
147
|
+
/**
|
|
148
|
+
* Importable installed package NAMES (TS/JS editors). When provided, the
|
|
149
|
+
* editor suggests these while the cursor is inside an import specifier
|
|
150
|
+
* string (`import {} from "lod"` -> `lodash`), solving the lazy-ATA
|
|
151
|
+
* catch-22 where no module is registered until its name is typed. Must
|
|
152
|
+
* already exclude `@types/*` companions. Injected by the consumer.
|
|
153
|
+
*/
|
|
154
|
+
importablePackages?: string[];
|
|
155
|
+
/**
|
|
156
|
+
* Whether to show the "expand editor" affordance that opens the editor in a
|
|
157
|
+
* large full-screen overlay for comfortably editing big scripts. Defaults to
|
|
158
|
+
* `true`. Set `false` to suppress it (e.g. for tiny single-line snippets).
|
|
159
|
+
*/
|
|
160
|
+
allowPopout?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Optional override for the overlay dialog title. When omitted, the title is
|
|
163
|
+
* derived from `language` (e.g. "Edit script - TypeScript"). Lets a consumer
|
|
164
|
+
* surface a field-specific label (e.g. a DynamicForm field name) while
|
|
165
|
+
* keeping `@checkstack/ui` plugin-agnostic.
|
|
166
|
+
*/
|
|
167
|
+
title?: string;
|
|
109
168
|
}
|