@bushscad/cli 0.0.0-stage → 0.3.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @author Aidan Boissonneault
3
+ * @file index.ts
4
+ * @purpose Public entry point for cli package.
5
+ * @last-edited October 6, 2026
6
+ */
7
+
8
+ export { parseCompilerRequest, parseServiceRequest } from './request-parser.js';
9
+ export type { CompilerRequestParsePolicy } from './request-parser.js';
10
+ export {
11
+ parseServiceMessage,
12
+ parseServiceMessageLine,
13
+ } from './message-parser.js';
14
+ export type {
15
+ BufferDto,
16
+ CompilerEngine,
17
+ CompilerMetrics,
18
+ CompilerPoolStatus,
19
+ CompilerRequest,
20
+ CompilerRequestKind,
21
+ DrawingOutputMode,
22
+ MeshDto,
23
+ ServiceMessage,
24
+ ServicePoolMessage,
25
+ ServiceRequest,
26
+ ServiceResponse,
27
+ ServiceTraceMessage,
28
+ } from './types.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @author Aidan Boissonneault
3
+ * @file json.ts
4
+ * @purpose Provides json behavior for the dto in cli package.
5
+ * @last-edited October 6, 2026
6
+ */
7
+
8
+ import type { CompileError } from '@bushscad/ir';
9
+
10
+ /**
11
+ * Result of maybe error, including success flag and errors.
12
+ */
13
+ type MaybeErrorResult = {
14
+ ok: boolean;
15
+ errors?: CompileError[];
16
+ };
17
+
18
+ /**
19
+ * Handles plain compile errors in the dto flow.
20
+ */
21
+ function plainCompileErrors(errors: CompileError[]): CompileError[] {
22
+ return errors.map((error) => ({
23
+ message: error.message,
24
+ span: error.span,
25
+ }));
26
+ }
27
+
28
+ /**
29
+ * Handles stringify cli result in the dto flow.
30
+ */
31
+ export function stringifyCliResult<T extends MaybeErrorResult>(
32
+ result: T,
33
+ ): string {
34
+ if (result.ok || !result.errors) return JSON.stringify(result);
35
+ return JSON.stringify({
36
+ ...result,
37
+ errors: plainCompileErrors(result.errors),
38
+ });
39
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @author Aidan Boissonneault
3
+ * @file meshDto.ts
4
+ * @purpose Provides mesh dto behavior for the dto in cli package.
5
+ * @last-edited October 6, 2026
6
+ */
7
+
8
+ import { meshPresentationMetadata, type MeshData } from '@bushscad/ir';
9
+ import type { MeshDto } from './types.js';
10
+
11
+ /**
12
+ * Converts internal mesh data into a serializable DTO.
13
+ * Mesh data contains typed arrays which must be converted
14
+ * for JSON transport and service responses.
15
+ */
16
+ export function meshToDto(mesh: MeshData): MeshDto {
17
+ return {
18
+ ...meshPresentationMetadata(mesh),
19
+ vertices: Array.from(mesh.vertices),
20
+ normals: Array.from(mesh.normals),
21
+ indices: Array.from(mesh.indices),
22
+ ...(mesh.isHardwareVisual
23
+ ? { isHardwareVisual: true, hardwareKind: mesh.hardwareKind }
24
+ : {}),
25
+ };
26
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @author Aidan Boissonneault
3
+ * @file message-parser.ts
4
+ * @purpose Provides message parser behavior for the dto in cli package.
5
+ * @last-edited October 6, 2026
6
+ */
7
+
8
+ import type {
9
+ CompilerPoolStatus,
10
+ ServiceMessage,
11
+ ServicePoolMessage,
12
+ ServiceResponse,
13
+ ServiceTraceMessage,
14
+ } from './types.js';
15
+
16
+ /**
17
+ * Parses one compiler-service stdout line into its canonical message union.
18
+ *
19
+ * Why: hosts must reject malformed or ambiguous child-process output instead
20
+ * of trusting a TypeScript cast across the process boundary.
21
+ */
22
+ export function parseServiceMessageLine(line: string): ServiceMessage {
23
+ let value: unknown;
24
+ try {
25
+ value = JSON.parse(line);
26
+ } catch {
27
+ throw new TypeError('Compiler service emitted invalid JSON.');
28
+ }
29
+ return parseServiceMessage(value);
30
+ }
31
+
32
+ /** Validates the stable envelope and discriminants of a service message. */
33
+ export function parseServiceMessage(value: unknown): ServiceMessage {
34
+ const record = objectRecord(value, 'Compiler service message');
35
+ if (record.kind === 'pool-status') return parsePoolMessage(record);
36
+ if (record.kind === 'occt-trace') return parseTraceMessage(record);
37
+ return parseResponse(record);
38
+ }
39
+
40
+ /**
41
+ * Parses response from the supplied source into the dto representation.
42
+ */
43
+ function parseResponse(record: Record<string, unknown>): ServiceResponse {
44
+ integer(record.id, 'Compiler response id');
45
+ responseKind(record.kind);
46
+ if (typeof record.ok !== 'boolean')
47
+ throw new TypeError('Compiler response ok must be boolean.');
48
+ if (!Array.isArray(record.env))
49
+ throw new TypeError('Compiler response env must be an array.');
50
+ finiteNumber(record.elapsedMs, 'Compiler response elapsedMs');
51
+ if (!record.ok && !Array.isArray(record.errors)) {
52
+ throw new TypeError('Failed compiler response must include errors.');
53
+ }
54
+ return record as ServiceResponse;
55
+ }
56
+
57
+ /**
58
+ * Parses trace message from the supplied source into the dto representation.
59
+ */
60
+ function parseTraceMessage(
61
+ record: Record<string, unknown>,
62
+ ): ServiceTraceMessage {
63
+ integer(record.id, 'Compiler trace id');
64
+ const event = objectRecord(record.event, 'Compiler trace event');
65
+ stringValue(event.kind, 'Compiler trace event kind');
66
+ finiteNumber(event.timestampMs, 'Compiler trace timestampMs');
67
+ if (event.schemaVersion !== undefined) validateVersionedTrace(event);
68
+ return record as ServiceTraceMessage;
69
+ }
70
+
71
+ /**
72
+ * Validates versioned trace and reports any violations.
73
+ */
74
+ function validateVersionedTrace(event: Record<string, unknown>): void {
75
+ if (event.schemaVersion !== 2)
76
+ throw new TypeError('Compiler trace schemaVersion must be 2.');
77
+ for (const key of ['buildId', 'eventId', 'engine', 'phase', 'state'])
78
+ stringValue(event[key], `Compiler trace ${key}`);
79
+ if (!['classic', 'parallel'].includes(String(event.engine)))
80
+ throw new TypeError('Invalid compiler trace engine.');
81
+ }
82
+
83
+ /**
84
+ * Parses pool message from the supplied source into the dto representation.
85
+ */
86
+ function parsePoolMessage(record: Record<string, unknown>): ServicePoolMessage {
87
+ const status = objectRecord(record.status, 'Compiler pool status');
88
+ const state = status.state;
89
+ if (
90
+ !['warming', 'ready', 'busy', 'degraded', 'failed', 'stopped'].includes(
91
+ String(state),
92
+ )
93
+ ) {
94
+ throw new TypeError('Invalid compiler pool state.');
95
+ }
96
+ for (const key of [
97
+ 'desiredWorkers',
98
+ 'warmingWorkers',
99
+ 'readyWorkers',
100
+ 'busyWorkers',
101
+ 'failedWorkers',
102
+ 'startupMs',
103
+ 'aggregateWorkerRssBytes',
104
+ 'peakAggregateWorkerRssBytes',
105
+ ])
106
+ finiteNumber(status[key], `Compiler pool ${key}`);
107
+ return { kind: 'pool-status', status: status as CompilerPoolStatus };
108
+ }
109
+
110
+ /**
111
+ * Handles object record in the dto flow.
112
+ */
113
+ function objectRecord(value: unknown, label: string): Record<string, unknown> {
114
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
115
+ throw new TypeError(`${label} must be an object.`);
116
+ }
117
+ return value as Record<string, unknown>;
118
+ }
119
+
120
+ /**
121
+ * Handles integer in the dto flow.
122
+ */
123
+ function integer(value: unknown, label: string): number {
124
+ if (!Number.isSafeInteger(value))
125
+ throw new TypeError(`${label} must be an integer.`);
126
+ return value as number;
127
+ }
128
+
129
+ /**
130
+ * Handles finite number in the dto flow.
131
+ */
132
+ function finiteNumber(value: unknown, label: string): number {
133
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
134
+ throw new TypeError(`${label} must be a finite number.`);
135
+ }
136
+ return value;
137
+ }
138
+
139
+ /**
140
+ * Handles string value in the dto flow.
141
+ */
142
+ function stringValue(value: unknown, label: string): string {
143
+ if (typeof value !== 'string' || value.length === 0)
144
+ throw new TypeError(`${label} must be a non-empty string.`);
145
+ return value;
146
+ }
147
+
148
+ /**
149
+ * Handles response kind in the dto flow.
150
+ */
151
+ function responseKind(value: unknown): ServiceResponse['kind'] {
152
+ if (
153
+ !['check', 'preview', 'export', 'exportDrawings'].includes(String(value))
154
+ ) {
155
+ throw new TypeError('Invalid compiler response kind.');
156
+ }
157
+ return value as ServiceResponse['kind'];
158
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @author Aidan Boissonneault
3
+ * @file request-fields.ts
4
+ * @purpose Provides request fields behavior for the dto in cli package.
5
+ * @last-edited October 6, 2026
6
+ */
7
+
8
+ import type { CompilerRequestKind, DrawingOutputMode } from './types.js';
9
+
10
+ /**
11
+ * String map limits, including entries, key, display value, and total.
12
+ */
13
+ export type StringMapLimits = {
14
+ entries: number;
15
+ key: number;
16
+ value: number;
17
+ total: number;
18
+ };
19
+
20
+ /** Parses a bounded string map used for overlays and environment values. */
21
+ export function parseStringMap(
22
+ value: unknown,
23
+ label: string,
24
+ limits: StringMapLimits,
25
+ ): Record<string, string> | undefined {
26
+ if (value === undefined) return undefined;
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
28
+ throw new TypeError(`${label} must be a string map.`);
29
+ }
30
+ const entries = Object.entries(value as Record<string, unknown>);
31
+ if (entries.length > limits.entries)
32
+ throw new TypeError(`${label} has too many entries.`);
33
+ const result = Object.create(null) as Record<string, string>;
34
+ let total = 0;
35
+ for (const [key, entry] of entries) {
36
+ if (
37
+ !boundedString(key, limits.key) ||
38
+ !boundedString(entry, limits.value)
39
+ ) {
40
+ throw new TypeError(`${label} contains an invalid entry.`);
41
+ }
42
+ total += key.length + entry.length;
43
+ if (total > limits.total)
44
+ throw new TypeError(`${label} exceeds the total size limit.`);
45
+ result[key] = entry;
46
+ }
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Parses optional index from the supplied source into the dto representation.
52
+ */
53
+ export function parseOptionalIndex(
54
+ value: unknown,
55
+ label: string,
56
+ ): number | undefined {
57
+ if (value === undefined) return undefined;
58
+ if (
59
+ !Number.isSafeInteger(value) ||
60
+ (value as number) < 0 ||
61
+ (value as number) > 10_000_000
62
+ ) {
63
+ throw new TypeError(`${label} must be a bounded non-negative integer.`);
64
+ }
65
+ return value as number;
66
+ }
67
+
68
+ /**
69
+ * Parses suppressed indices from the supplied source into the dto representation.
70
+ */
71
+ export function parseSuppressedIndices(value: unknown): number[] | undefined {
72
+ if (value === undefined) return undefined;
73
+ if (!Array.isArray(value) || value.length > 10_000) {
74
+ throw new TypeError(
75
+ 'Suppressed operation indices must be a bounded array.',
76
+ );
77
+ }
78
+ return value.map(
79
+ (entry) =>
80
+ parseOptionalIndex(entry, 'Suppressed operation index') as number,
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Parses entrypoint mode from the supplied source into the dto representation.
86
+ */
87
+ export function parseEntrypointMode(
88
+ value: unknown,
89
+ ): 'workspace' | 'requested-file' {
90
+ if (value === undefined || value === 'workspace') return 'workspace';
91
+ if (value === 'requested-file') return value;
92
+ throw new TypeError('Invalid compiler entrypoint mode.');
93
+ }
94
+
95
+ /**
96
+ * Parses format from the supplied source into the dto representation.
97
+ */
98
+ export function parseFormat(
99
+ value: unknown,
100
+ kind: CompilerRequestKind,
101
+ ): 'step' | 'stl' | undefined {
102
+ if (value !== undefined && value !== 'step' && value !== 'stl') {
103
+ throw new TypeError('Invalid compiler export format.');
104
+ }
105
+ if (kind === 'export' && value === undefined)
106
+ throw new TypeError('Compiler export format is required.');
107
+ if (kind !== 'export' && value !== undefined)
108
+ throw new TypeError('Compiler format is only valid for export.');
109
+ return value as 'step' | 'stl' | undefined;
110
+ }
111
+
112
+ /**
113
+ * Parses optional boolean from the supplied source into the dto representation.
114
+ */
115
+ export function parseOptionalBoolean(
116
+ value: unknown,
117
+ label: string,
118
+ ): boolean | undefined {
119
+ if (value === undefined || typeof value === 'boolean') return value;
120
+ throw new TypeError(`${label} must be a boolean.`);
121
+ }
122
+
123
+ /**
124
+ * Parses engine from the supplied source into the dto representation.
125
+ */
126
+ export function parseEngine(
127
+ value: unknown,
128
+ ): 'classic' | 'parallel' | undefined {
129
+ if (value === undefined || value === 'classic' || value === 'parallel') {
130
+ return value;
131
+ }
132
+ throw new TypeError('Invalid compiler engine.');
133
+ }
134
+
135
+ /** Restricts host-owned drawing writes to the drawing export request. */
136
+ export function parseDrawingOutputMode(
137
+ value: unknown,
138
+ kind: CompilerRequestKind,
139
+ ): DrawingOutputMode | undefined {
140
+ if (
141
+ value !== undefined &&
142
+ value !== 'write-source-paths' &&
143
+ value !== 'return-content'
144
+ )
145
+ throw new TypeError('Invalid drawing output mode.');
146
+ if (kind !== 'exportDrawings' && value !== undefined)
147
+ throw new TypeError(
148
+ 'Drawing output mode is only valid for drawing export.',
149
+ );
150
+ return value as DrawingOutputMode | undefined;
151
+ }
152
+
153
+ /**
154
+ * Handles assert preview controls in the dto flow.
155
+ */
156
+ export function assertPreviewControls(
157
+ kind: CompilerRequestKind,
158
+ controls: readonly unknown[],
159
+ ): void {
160
+ if (kind !== 'preview' && controls.some((value) => value !== undefined)) {
161
+ throw new TypeError(
162
+ 'Preview controls are only valid for preview requests.',
163
+ );
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Handles bounded string in the dto flow.
169
+ */
170
+ function boundedString(value: unknown, maximum: number): value is string {
171
+ return (
172
+ typeof value === 'string' && value.length > 0 && value.length <= maximum
173
+ );
174
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @author Aidan Boissonneault
3
+ * @file request-parser.ts
4
+ * @purpose Provides request parser behavior for the dto in cli package.
5
+ * @last-edited October 6, 2026
6
+ */
7
+
8
+ import { isAbsolute, relative, resolve } from 'node:path';
9
+ import {
10
+ assertPreviewControls,
11
+ parseEngine,
12
+ parseDrawingOutputMode,
13
+ parseEntrypointMode,
14
+ parseFormat,
15
+ parseOptionalBoolean,
16
+ parseOptionalIndex,
17
+ parseStringMap,
18
+ parseSuppressedIndices,
19
+ } from './request-fields.js';
20
+ import type {
21
+ CompilerRequest,
22
+ CompilerRequestKind,
23
+ ServiceRequest,
24
+ } from './types.js';
25
+
26
+ const REQUEST_KEYS = new Set([
27
+ 'id',
28
+ 'kind',
29
+ 'filePath',
30
+ 'rootPath',
31
+ 'entrypointMode',
32
+ 'overlays',
33
+ 'env',
34
+ 'format',
35
+ 'cursorOpIndex',
36
+ 'suppressedOpIndices',
37
+ 'occtTrace',
38
+ 'engine',
39
+ 'drawingOutputMode',
40
+ ]);
41
+ const REQUEST_KINDS = new Set<unknown>([
42
+ 'check',
43
+ 'preview',
44
+ 'export',
45
+ 'exportDrawings',
46
+ ]);
47
+
48
+ /**
49
+ * Compiler request parse policy, including normalize absolute path, assert path allowed, and
50
+ * path is within.
51
+ */
52
+ export type CompilerRequestParsePolicy = {
53
+ normalizeAbsolutePath?: (value: unknown, label: string) => string;
54
+ assertPathAllowed?: (path: string) => void;
55
+ pathIsWithin?: (rootPath: string, candidate: string) => boolean;
56
+ };
57
+
58
+ /**
59
+ * Parses a host request through the canonical compiler-service contract.
60
+ *
61
+ * Why: VS Code and the CLI process must enforce identical field,
62
+ * size, and operation rules before native compiler work can begin.
63
+ */
64
+ export function parseCompilerRequest(
65
+ value: unknown,
66
+ forcedKind?: CompilerRequestKind,
67
+ policy: CompilerRequestParsePolicy = {},
68
+ ): CompilerRequest {
69
+ const raw = requestRecord(value);
70
+ if (raw.kind !== undefined) requestKind(raw.kind);
71
+ const kind = requestKind(forcedKind ?? raw.kind);
72
+ const filePath = absolutePath(raw.filePath, 'Compiler file path', policy);
73
+ const rootPath = optionalRootPath(raw.rootPath, policy);
74
+ assertWithin(rootPath, filePath, 'Compiler file path', policy);
75
+ policy.assertPathAllowed?.(filePath);
76
+ if (rootPath) policy.assertPathAllowed?.(rootPath);
77
+
78
+ const overlays = parseStringMap(raw.overlays, 'Compiler overlays', {
79
+ entries: 1_024,
80
+ key: 32_767,
81
+ value: 32 * 1024 * 1024,
82
+ total: 64 * 1024 * 1024,
83
+ });
84
+ validateOverlayPaths(overlays, rootPath, policy);
85
+ const env = parseStringMap(raw.env, 'Compiler environment', {
86
+ entries: 256,
87
+ key: 128,
88
+ value: 4_096,
89
+ total: 256 * 4_224,
90
+ });
91
+ const format = parseFormat(raw.format, kind);
92
+ const cursorOpIndex = parseOptionalIndex(
93
+ raw.cursorOpIndex,
94
+ 'Cursor operation index',
95
+ );
96
+ const suppressedOpIndices = parseSuppressedIndices(raw.suppressedOpIndices);
97
+ const occtTrace = parseOptionalBoolean(raw.occtTrace, 'OCCT trace flag');
98
+ const engine = parseEngine(raw.engine);
99
+ const drawingOutputMode = parseDrawingOutputMode(raw.drawingOutputMode, kind);
100
+ assertPreviewControls(kind, [cursorOpIndex, suppressedOpIndices, occtTrace]);
101
+
102
+ return {
103
+ kind,
104
+ filePath,
105
+ ...(rootPath ? { rootPath } : {}),
106
+ entrypointMode: parseEntrypointMode(raw.entrypointMode),
107
+ ...(overlays ? { overlays } : {}),
108
+ ...(env ? { env } : {}),
109
+ ...(format ? { format } : {}),
110
+ ...(cursorOpIndex !== undefined ? { cursorOpIndex } : {}),
111
+ ...(suppressedOpIndices ? { suppressedOpIndices } : {}),
112
+ ...(occtTrace !== undefined ? { occtTrace } : {}),
113
+ ...(engine ? { engine } : {}),
114
+ ...(drawingOutputMode ? { drawingOutputMode } : {}),
115
+ };
116
+ }
117
+
118
+ /** Parses an ID-bearing JSON-line request received by the CLI service. */
119
+ export function parseServiceRequest(value: unknown): ServiceRequest {
120
+ const raw = requestRecord(value);
121
+ const id = parseOptionalIndex(raw.id, 'Compiler request id');
122
+ if (id === undefined) throw new TypeError('Compiler request id is required.');
123
+ return { ...parseCompilerRequest(raw), id };
124
+ }
125
+
126
+ /**
127
+ * Handles request record in the dto flow.
128
+ */
129
+ function requestRecord(value: unknown): Record<string, unknown> {
130
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
131
+ throw new TypeError('Compiler request must be an object.');
132
+ }
133
+ const record = value as Record<string, unknown>;
134
+ for (const key of Object.keys(record)) {
135
+ if (!REQUEST_KEYS.has(key))
136
+ throw new TypeError(`Unknown compiler request field: ${key}`);
137
+ }
138
+ return record;
139
+ }
140
+
141
+ /**
142
+ * Handles request kind in the dto flow.
143
+ */
144
+ function requestKind(value: unknown): CompilerRequestKind {
145
+ if (!REQUEST_KINDS.has(value))
146
+ throw new TypeError('Invalid compiler request kind.');
147
+ return value as CompilerRequestKind;
148
+ }
149
+
150
+ /**
151
+ * Handles absolute path in the dto flow.
152
+ */
153
+ function absolutePath(
154
+ value: unknown,
155
+ label: string,
156
+ policy: CompilerRequestParsePolicy,
157
+ ): string {
158
+ if (policy.normalizeAbsolutePath)
159
+ return policy.normalizeAbsolutePath(value, label);
160
+ if (typeof value !== 'string' || !isAbsolute(value)) {
161
+ throw new TypeError(`${label} must be an absolute path.`);
162
+ }
163
+ return resolve(value);
164
+ }
165
+
166
+ /**
167
+ * Handles optional root path in the dto flow.
168
+ */
169
+ function optionalRootPath(
170
+ value: unknown,
171
+ policy: CompilerRequestParsePolicy,
172
+ ): string | undefined {
173
+ if (value === undefined || value === null) return undefined;
174
+ return absolutePath(value, 'Compiler root path', policy);
175
+ }
176
+
177
+ /**
178
+ * Validates overlay paths and reports any violations.
179
+ */
180
+ function validateOverlayPaths(
181
+ overlays: Record<string, string> | undefined,
182
+ rootPath: string | undefined,
183
+ policy: CompilerRequestParsePolicy,
184
+ ): void {
185
+ for (const path of Object.keys(overlays ?? {})) {
186
+ const normalized = absolutePath(path, 'Overlay path', policy);
187
+ assertWithin(rootPath, normalized, 'Compiler overlay', policy);
188
+ policy.assertPathAllowed?.(normalized);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Handles assert within in the dto flow.
194
+ */
195
+ function assertWithin(
196
+ rootPath: string | undefined,
197
+ candidate: string,
198
+ label: string,
199
+ policy: CompilerRequestParsePolicy,
200
+ ): void {
201
+ if (!rootPath) return;
202
+ const within = policy.pathIsWithin
203
+ ? policy.pathIsWithin(rootPath, candidate)
204
+ : defaultPathIsWithin(rootPath, candidate);
205
+ if (!within) throw new Error(`${label} is outside the requested root.`);
206
+ }
207
+
208
+ /**
209
+ * Returns the default path is within used by this module.
210
+ */
211
+ function defaultPathIsWithin(rootPath: string, candidate: string): boolean {
212
+ const child = relative(rootPath, candidate);
213
+ return child === '' || (!child.startsWith('..') && !isAbsolute(child));
214
+ }