@typecad/cuttlefish 1.0.0-alpha.10 → 1.0.0-alpha.12

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.
@@ -12,6 +12,13 @@ import ts from "typescript";
12
12
  import { safeValidateConfig } from "./config-schema.js";
13
13
  /** The filename we search for when walking up directories. */
14
14
  const CONFIG_FILENAME = "cuttlefish.config.ts";
15
+ /** Top-level keys recognized by CuttlefishConfigSchema — used to warn about
16
+ * misspelled keys that the AST extraction would otherwise drop silently. */
17
+ const KNOWN_TOP_LEVEL_KEYS = new Set([
18
+ "entry", "target", "mcu", "board", "contract", "framework", "psram",
19
+ "output", "frameworkData", "include", "exclude", "test", "toolchain",
20
+ "console", "native", "zephyr", "display",
21
+ ]);
15
22
  /**
16
23
  * Search upward from `startDir` for a file named `cuttlefish.config.ts`.
17
24
  * Returns the absolute path on success, `undefined` if none is found.
@@ -30,15 +37,21 @@ export function findConfigFile(startDir) {
30
37
  }
31
38
  return undefined;
32
39
  }
33
- // ---------------------------------------------------------------------------
34
- // AST helpers — extract scalar values from a TS object literal
35
- // ---------------------------------------------------------------------------
36
40
  function unwrapTypeCast(node) {
37
41
  let curr = node;
38
- while (ts.isAsExpression(curr) || ts.isTypeAssertionExpression(curr)) {
39
- curr = curr.expression;
42
+ for (;;) {
43
+ if (ts.isAsExpression(curr) || ts.isTypeAssertionExpression(curr) || ts.isParenthesizedExpression(curr)) {
44
+ curr = curr.expression;
45
+ continue;
46
+ }
47
+ // satisfies is TS ≥4.9 — guard for older typings, then cast for .expression.
48
+ const isSatisfies = ts.isSatisfiesExpression;
49
+ if (typeof isSatisfies === "function" && isSatisfies(curr)) {
50
+ curr = curr.expression;
51
+ continue;
52
+ }
53
+ return curr;
40
54
  }
41
- return curr;
42
55
  }
43
56
  function getStringLiteral(node) {
44
57
  const unwrapped = unwrapTypeCast(node);
@@ -55,20 +68,51 @@ function getScalarValue(node) {
55
68
  if (ts.isNumericLiteral(unwrapped)) {
56
69
  return Number(unwrapped.text);
57
70
  }
71
+ // Negative (and explicitly positive) number literals parse as
72
+ // PrefixUnaryExpression — `reset: -1` used to be silently dropped.
73
+ if (ts.isPrefixUnaryExpression(unwrapped)
74
+ && (unwrapped.operator === ts.SyntaxKind.MinusToken || unwrapped.operator === ts.SyntaxKind.PlusToken)
75
+ && ts.isNumericLiteral(unwrapped.operand)) {
76
+ const magnitude = Number(unwrapped.operand.text);
77
+ return unwrapped.operator === ts.SyntaxKind.MinusToken ? -magnitude : magnitude;
78
+ }
58
79
  if (unwrapped.kind === ts.SyntaxKind.TrueKeyword)
59
80
  return true;
60
81
  if (unwrapped.kind === ts.SyntaxKind.FalseKeyword)
61
82
  return false;
62
83
  return undefined;
63
84
  }
85
+ /** True for `null` / `undefined` literals — inline literals, but not values
86
+ * the config shape supports; callers warn with an accurate message instead
87
+ * of the "variables/ternaries" text. */
88
+ function isNullishLiteral(node) {
89
+ const kind = unwrapTypeCast(node).kind;
90
+ return kind === ts.SyntaxKind.NullKeyword || kind === ts.SyntaxKind.UndefinedKeyword;
91
+ }
64
92
  /**
65
93
  * Walk an object literal and collect all scalar (string / number / boolean)
66
94
  * property values into a flat dot-path map — exactly like board-resolver.ts.
67
95
  */
68
- function walkObjectLiteral(obj, prefix, out) {
96
+ /** Property key text for Identifier/StringLiteral names, else undefined
97
+ * (SpreadAssignment has no name; computed keys are not static text). */
98
+ function propertyKeyName(prop) {
99
+ const name = prop.name;
100
+ if (!name)
101
+ return undefined;
102
+ return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
103
+ }
104
+ function describePropertyKey(prop) {
105
+ return propertyKeyName(prop) ?? "<unnamed>";
106
+ }
107
+ function walkObjectLiteral(obj, prefix, out, warn) {
69
108
  for (const prop of obj.properties) {
70
- if (!ts.isPropertyAssignment(prop))
109
+ if (!ts.isPropertyAssignment(prop)) {
110
+ if (warn) {
111
+ const label = prefix ? `${prefix}.${describePropertyKey(prop)}` : describePropertyKey(prop);
112
+ warn(`'${label}' uses ${ts.SyntaxKind[prop.kind]} syntax (shorthand/spread/method) — only property assignments are supported, ignored.`);
113
+ }
71
114
  continue;
115
+ }
72
116
  const key = ts.isIdentifier(prop.name)
73
117
  ? prop.name.text
74
118
  : ts.isStringLiteral(prop.name)
@@ -78,13 +122,26 @@ function walkObjectLiteral(obj, prefix, out) {
78
122
  continue;
79
123
  const fullKey = prefix ? `${prefix}.${key}` : key;
80
124
  if (ts.isObjectLiteralExpression(prop.initializer)) {
81
- walkObjectLiteral(prop.initializer, fullKey, out);
125
+ walkObjectLiteral(prop.initializer, fullKey, out, warn);
126
+ }
127
+ else if (ts.isArrayLiteralExpression(prop.initializer)) {
128
+ // Arrays are extracted by the dedicated array extractors
129
+ // (output.extraFlags, zephyr.cmakeArgs, native.libraries, …), not the
130
+ // flat scalar walk — skip here without warning.
82
131
  }
83
132
  else {
84
133
  const value = getScalarValue(prop.initializer);
85
134
  if (value !== undefined) {
86
135
  out.set(fullKey, value);
87
136
  }
137
+ else if (warn) {
138
+ // Message text must stay byte-identical to extractObjectAsRecord's —
139
+ // sections walked by both (native, display) rely on the warn-site
140
+ // dedup to print one warning per dropped value, not two.
141
+ warn(isNullishLiteral(prop.initializer)
142
+ ? `'${fullKey}' is a null/undefined literal — not a supported config value, ignored.`
143
+ : `'${fullKey}' is not an inline literal (variables, ternaries, and template substitutions are not evaluated) — ignored.`);
144
+ }
88
145
  }
89
146
  }
90
147
  }
@@ -124,20 +181,18 @@ function navigateToObjectProperty(obj, path) {
124
181
  }
125
182
  return undefined;
126
183
  }
127
- function extractStringArray(obj, path) {
184
+ function extractStringArray(obj, path, warn) {
185
+ const label = path.join(".");
128
186
  const leaf = navigateToObjectProperty(obj, path);
129
- if (!leaf || !ts.isArrayLiteralExpression(leaf))
187
+ if (!leaf || !ts.isArrayLiteralExpression(leaf)) {
188
+ if (leaf && warn)
189
+ warn(`'${label}' is not an inline array literal — ignored.`);
130
190
  return undefined;
131
- const result = [];
132
- for (const elem of leaf.elements) {
133
- const s = getStringLiteral(elem);
134
- if (s === undefined)
135
- return undefined;
136
- result.push(s);
137
191
  }
138
- return result;
192
+ return extractStringArrayFromArrayLiteral(leaf, warn, label);
139
193
  }
140
- function extractStringRecord(obj, path) {
194
+ function extractStringRecord(obj, path, warn) {
195
+ const label = path.join(".");
141
196
  const leaf = navigateToObjectProperty(obj, path);
142
197
  if (!leaf || !ts.isObjectLiteralExpression(leaf))
143
198
  return undefined;
@@ -153,8 +208,11 @@ function extractStringRecord(obj, path) {
153
208
  if (!key)
154
209
  continue;
155
210
  const value = getStringLiteral(prop.initializer);
156
- if (value === undefined)
211
+ if (value === undefined) {
212
+ if (warn)
213
+ warn(`'${label}.${key}' is not a string literal — the whole record is ignored.`);
157
214
  return undefined;
215
+ }
158
216
  result[key] = value;
159
217
  }
160
218
  if (Object.keys(result).length === 0)
@@ -165,11 +223,18 @@ function extractStringRecord(obj, path) {
165
223
  * Recursively extract an object literal as Record<string, unknown>.
166
224
  * Handles strings, numbers, booleans, string arrays, and nested objects.
167
225
  */
168
- function extractObjectAsRecord(obj) {
226
+ function extractObjectAsRecord(obj, warn, prefix = "") {
169
227
  const result = {};
170
228
  for (const prop of obj.properties) {
171
- if (!ts.isPropertyAssignment(prop))
229
+ if (!ts.isPropertyAssignment(prop)) {
230
+ if (warn) {
231
+ // Byte-identical to walkObjectLiteral's message for the same property
232
+ // so the warn-site dedup collapses the double walk into one warning.
233
+ const label = prefix ? `${prefix}.${describePropertyKey(prop)}` : describePropertyKey(prop);
234
+ warn(`'${label}' uses ${ts.SyntaxKind[prop.kind]} syntax (shorthand/spread/method) — only property assignments are supported, ignored.`);
235
+ }
172
236
  continue;
237
+ }
173
238
  const key = ts.isIdentifier(prop.name)
174
239
  ? prop.name.text
175
240
  : ts.isStringLiteral(prop.name)
@@ -177,29 +242,40 @@ function extractObjectAsRecord(obj) {
177
242
  : undefined;
178
243
  if (!key)
179
244
  continue;
245
+ const fullKey = prefix ? `${prefix}.${key}` : key;
180
246
  const init = prop.initializer;
181
247
  if (ts.isObjectLiteralExpression(init)) {
182
- result[key] = extractObjectAsRecord(init);
248
+ result[key] = extractObjectAsRecord(init, warn, fullKey);
183
249
  }
184
250
  else if (ts.isArrayLiteralExpression(init)) {
185
- const arr = extractStringArrayFromArrayLiteral(init);
251
+ const arr = extractStringArrayFromArrayLiteral(init, warn, fullKey);
186
252
  if (arr)
187
253
  result[key] = arr;
188
254
  }
189
255
  else {
190
256
  const scalar = getScalarValue(init);
191
- if (scalar !== undefined)
257
+ if (scalar !== undefined) {
192
258
  result[key] = scalar;
259
+ }
260
+ else if (warn) {
261
+ // Keep byte-identical to walkObjectLiteral's message (see above).
262
+ warn(isNullishLiteral(init)
263
+ ? `'${fullKey}' is a null/undefined literal — not a supported config value, ignored.`
264
+ : `'${fullKey}' is not an inline literal (variables, ternaries, and template substitutions are not evaluated) — ignored.`);
265
+ }
193
266
  }
194
267
  }
195
268
  return result;
196
269
  }
197
- function extractStringArrayFromArrayLiteral(node) {
270
+ function extractStringArrayFromArrayLiteral(node, warn, label = "array") {
198
271
  const result = [];
199
272
  for (const elem of node.elements) {
200
273
  const s = getStringLiteral(elem);
201
- if (s === undefined)
274
+ if (s === undefined) {
275
+ if (warn)
276
+ warn(`'${label}' has a non-string-literal element — the whole array is ignored.`);
202
277
  return undefined;
278
+ }
203
279
  result.push(s);
204
280
  }
205
281
  return result;
@@ -207,7 +283,7 @@ function extractStringArrayFromArrayLiteral(node) {
207
283
  /**
208
284
  * Extract a top-level config section as a generic Record<string, unknown>.
209
285
  */
210
- function extractFrameworkSection(obj, sectionName) {
286
+ function extractFrameworkSection(obj, sectionName, warn) {
211
287
  for (const prop of obj.properties) {
212
288
  if (!ts.isPropertyAssignment(prop))
213
289
  continue;
@@ -218,9 +294,12 @@ function extractFrameworkSection(obj, sectionName) {
218
294
  : undefined;
219
295
  if (key !== sectionName)
220
296
  continue;
221
- if (!ts.isObjectLiteralExpression(prop.initializer))
297
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
298
+ if (warn)
299
+ warn(`'${sectionName}' section is not an inline object literal — ignored.`);
222
300
  return undefined;
223
- return extractObjectAsRecord(prop.initializer);
301
+ }
302
+ return extractObjectAsRecord(prop.initializer, warn, sectionName);
224
303
  }
225
304
  return undefined;
226
305
  }
@@ -251,11 +330,14 @@ export function parseConfigFile(configPath) {
251
330
  }
252
331
  // export default config; (ExportAssignment with an identifier)
253
332
  if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
254
- if (ts.isIdentifier(stmt.expression)) {
255
- defaultExportName = stmt.expression.text;
333
+ // Unwrap `export default { ... } satisfies CuttlefishConfig` /
334
+ // `as const` / parenthesized forms down to the underlying expression.
335
+ const expr = unwrapTypeCast(stmt.expression);
336
+ if (ts.isIdentifier(expr)) {
337
+ defaultExportName = expr.text;
256
338
  }
257
- else if (ts.isObjectLiteralExpression(stmt.expression)) {
258
- inlineDefaultObject = stmt.expression;
339
+ else if (ts.isObjectLiteralExpression(expr)) {
340
+ inlineDefaultObject = expr;
259
341
  }
260
342
  }
261
343
  }
@@ -263,16 +345,43 @@ export function parseConfigFile(configPath) {
263
345
  let configObject = inlineDefaultObject;
264
346
  if (!configObject && defaultExportName) {
265
347
  const decl = variableDecls.get(defaultExportName);
266
- if (decl?.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
267
- configObject = decl.initializer;
348
+ if (decl?.initializer) {
349
+ // `const config = { ... } satisfies CuttlefishConfig` parses the
350
+ // initializer as a SatisfiesExpression — unwrap to the object literal
351
+ // so the whole config isn't silently ignored.
352
+ const init = unwrapTypeCast(decl.initializer);
353
+ if (ts.isObjectLiteralExpression(init)) {
354
+ configObject = init;
355
+ }
268
356
  }
269
357
  }
270
358
  if (!configObject) {
271
359
  return undefined;
272
360
  }
361
+ // Collect drop warnings (values the AST-only parser can't evaluate) and
362
+ // print them once after parsing — silent drops here used to surface much
363
+ // later as missing -l flags / reverted defaults with no diagnostic.
364
+ const dropWarnings = [];
365
+ const seenWarnings = new Set();
366
+ const warn = (message) => {
367
+ const line = `${path.basename(configPath)}: ${message}`;
368
+ if (!seenWarnings.has(line)) {
369
+ seenWarnings.add(line);
370
+ dropWarnings.push(line);
371
+ }
372
+ };
373
+ // Typos in top-level keys (e.g. `output.optmize`) were dropped before
374
+ // schema validation, so the strict schema never saw them — check the raw
375
+ // source keys against the known set directly.
376
+ for (const prop of configObject.properties) {
377
+ const key = propertyKeyName(prop);
378
+ if (key && !KNOWN_TOP_LEVEL_KEYS.has(key)) {
379
+ warn(`unknown top-level key '${key}' — misspelled or unsupported.`);
380
+ }
381
+ }
273
382
  // Walk the object literal into a flat map.
274
383
  const flat = new Map();
275
- walkObjectLiteral(configObject, "", flat);
384
+ walkObjectLiteral(configObject, "", flat, warn);
276
385
  // Map flat keys to the resolved config shape.
277
386
  const resolved = { configPath };
278
387
  const entry = flat.get("entry");
@@ -298,8 +407,11 @@ export function parseConfigFile(configPath) {
298
407
  if (typeof framework === "string")
299
408
  resolved.framework = framework;
300
409
  const psram = flat.get("psram");
301
- if (psram === "opi" || psram === "quad")
410
+ if (typeof psram === "string") {
411
+ // Assign any string (including "") so the schema's PsramType enum rejects
412
+ // typos ('octal') with a validation error instead of silently dropping it.
302
413
  resolved.psram = psram;
414
+ }
303
415
  const outputFramework = flat.get("output.framework");
304
416
  if (typeof outputFramework === "string")
305
417
  resolved.outputFramework = outputFramework;
@@ -319,19 +431,19 @@ export function parseConfigFile(configPath) {
319
431
  };
320
432
  }
321
433
  // Extract structured fields that the flat walker cannot handle.
322
- const outputExtraFlags = extractStringArray(configObject, ["output", "extraFlags"]);
434
+ const outputExtraFlags = extractStringArray(configObject, ["output", "extraFlags"], warn);
323
435
  if (outputExtraFlags)
324
436
  resolved.outputExtraFlags = outputExtraFlags;
325
- const outputDefines = extractStringRecord(configObject, ["output", "defines"]);
437
+ const outputDefines = extractStringRecord(configObject, ["output", "defines"], warn);
326
438
  if (outputDefines)
327
439
  resolved.outputDefines = outputDefines;
328
- const nativeSection = extractFrameworkSection(configObject, "native");
440
+ const nativeSection = extractFrameworkSection(configObject, "native", warn);
329
441
  if (nativeSection) {
330
442
  resolved.frameworkConfig = nativeSection;
331
443
  }
332
444
  // Parse zephyr-specific config section.
333
- const zephyrKconfig = extractStringRecord(configObject, ["zephyr", "kconfig"]);
334
- const zephyrCmakeArgs = extractStringArray(configObject, ["zephyr", "cmakeArgs"]);
445
+ const zephyrKconfig = extractStringRecord(configObject, ["zephyr", "kconfig"], warn);
446
+ const zephyrCmakeArgs = extractStringArray(configObject, ["zephyr", "cmakeArgs"], warn);
335
447
  const zephyrRunner = flat.get("zephyr.runner");
336
448
  if (zephyrKconfig || zephyrCmakeArgs || typeof zephyrRunner === "string") {
337
449
  resolved.zephyrConfig = {
@@ -341,7 +453,10 @@ export function parseConfigFile(configPath) {
341
453
  };
342
454
  }
343
455
  // Parse display profile config (nested object with profile name, wiring, touch)
344
- const displaySection = extractFrameworkSection(configObject, "display");
456
+ const displaySection = extractFrameworkSection(configObject, "display", warn);
457
+ // extractObjectAsRecord walks literals into a loose record; the shape is
458
+ // checked downstream (schema passthrough + display-profile resolution), so
459
+ // bridge it to the declared DisplayConfig type at this single boundary.
345
460
  if (displaySection)
346
461
  resolved.display = displaySection;
347
462
  // Validate the parsed config against the Zod schema.
@@ -359,7 +474,9 @@ export function parseConfigFile(configPath) {
359
474
  structuredForValidation.entry = resolved.entry;
360
475
  if (resolved.framework)
361
476
  structuredForValidation.framework = resolved.framework;
362
- if (resolved.psram)
477
+ // `!== undefined` (not truthiness) so an empty-string psram reaches the
478
+ // PsramType enum and fails validation instead of vanishing.
479
+ if (resolved.psram !== undefined)
363
480
  structuredForValidation.psram = resolved.psram;
364
481
  if (resolved.outputFramework || resolved.outputOptimize || resolved.outputOutDir || resolved.outputExtraFlags || resolved.outputDefines) {
365
482
  structuredForValidation.output = {
@@ -374,6 +491,10 @@ export function parseConfigFile(configPath) {
374
491
  structuredForValidation.console = resolved.console;
375
492
  if (resolved.zephyrConfig)
376
493
  structuredForValidation.zephyr = resolved.zephyrConfig;
494
+ if (resolved.frameworkConfig)
495
+ structuredForValidation.native = resolved.frameworkConfig;
496
+ if (resolved.display)
497
+ structuredForValidation.display = resolved.display;
377
498
  if (resolved.buildTarget) {
378
499
  structuredForValidation.frameworkData = { buildTarget: resolved.buildTarget };
379
500
  }
@@ -382,6 +503,12 @@ export function parseConfigFile(configPath) {
382
503
  const errorMsg = validation.errors.map(e => ` - ${e}`).join("\n");
383
504
  throw new Error(`Configuration validation failed for ${configPath}:\n${errorMsg}`);
384
505
  }
506
+ if (dropWarnings.length > 0) {
507
+ console.warn(`⚠ ${configPath}: some values were ignored (the parser only evaluates inline literals):`);
508
+ for (const w of dropWarnings) {
509
+ console.warn(` ${w}`);
510
+ }
511
+ }
385
512
  return resolved;
386
513
  }
387
514
  /**
@@ -440,6 +567,23 @@ export function generateVirtualTypeDeclaration(config, platformDeclarations) {
440
567
  " type Shared<T = unknown> = T;",
441
568
  " type Mutable<T = unknown> = T;",
442
569
  "",
570
+ " // SafeVariable: SEU-resistant storage. The transpiler lowers SafeVariable<number>",
571
+ " // to a C++ template with inverted-redundancy storage. Declared as an interface",
572
+ " // (not a type alias) so the TS type checker recognizes method calls.",
573
+ " // Arithmetic T only (integral or floating-point); string is rejected by a",
574
+ " // static_assert in the emitted C++ template.",
575
+ " interface SafeVariable<T = number> { set(value: T): void; get(): T; valid(): boolean; hasFault(): boolean; }",
576
+ " // SafeInt: chainable bounds-checked signed-integer arithmetic. The transpiler",
577
+ " // lowers SafeInt<number> to SafeInt<int32_t> (a C++ template with sticky-fault",
578
+ " // overflow detection). Signed integer T only — unsigned/bool/float/string are",
579
+ " // rejected by a static_assert in the emitted C++ template.",
580
+ " interface SafeInt<T = number> {",
581
+ " add(delta: T): SafeInt<T>; sub(delta: T): SafeInt<T>;",
582
+ " mul(factor: T): SafeInt<T>; divide(d: T): SafeInt<T>; mod(d: T): SafeInt<T>;",
583
+ " negate(): SafeInt<T>; absValue(): SafeInt<T>;",
584
+ " get(): T; hasFault(): boolean; valid(): boolean; reset(newValue: T): void;",
585
+ " }",
586
+ "",
443
587
  " // C-style explicit number types recognized by the transpiler",
444
588
  " type uint8_t = number;",
445
589
  " type int8_t = number;",
@@ -480,9 +624,9 @@ export function generateVirtualTypeDeclaration(config, platformDeclarations) {
480
624
  "",
481
625
  "declare module '@typecad/board' {",
482
626
  ` ${boardExport}`,
483
- " export type Owned<T = any> = T;",
484
- " export type Shared<T = any> = T;",
485
- " export type Mutable<T = any> = T;",
627
+ " export type Owned<T = unknown> = T;",
628
+ " export type Shared<T = unknown> = T;",
629
+ " export type Mutable<T = unknown> = T;",
486
630
  "}",
487
631
  "",
488
632
  "export {};",