@crossworks/content-core 0.230.43

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 (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,566 @@
1
+ /**
2
+ * The formula spec: a declarative description of a calculation that came out of
3
+ * a published standard, rather than a single expression string.
4
+ *
5
+ * A real engineering calculation is not one formula. Working from API RP 581
6
+ * Part 3 §5.3 as the motivating case, one "release quantity" model contains all
7
+ * four of these, and only the first is an expression:
8
+ *
9
+ * expressions scalar maths — the release-rate equations
10
+ * piecewise a branch — sonic vs subsonic, on a pressure threshold
11
+ * lookups keyed tables — a reduction factor per detection/isolation
12
+ * rating, a leak duration per rating AND hole size
13
+ * classifications prose rubrics mapping a described system to a rating.
14
+ * Human (or model) judgment, NOT arithmetic.
15
+ *
16
+ * Two decisions worth defending, because both were tempting to do otherwise:
17
+ *
18
+ * 1. Lookup tables are stored as DATA ROWS, never as a nested IF() chain in an
19
+ * expression. These tables come from a standard that gets revised; a changed
20
+ * factor should be a one-line diff a reviewer can hold against the printed
21
+ * table, not a re-reading of a forty-term conditional. Storing them as rows
22
+ * is also what makes `checkLookupCoverage` possible, which is the only
23
+ * reason we can prove a table is total over its keys instead of hoping.
24
+ *
25
+ * 2. Classifications are INPUTS, not computations. The criteria text lives in
26
+ * the spec so that a rating can be justified by citing the clause it matched,
27
+ * but nothing here tries to infer a rating from prose.
28
+ *
29
+ * Deliberately dependency-free and pure (no zod, no YAML) so it runs unchanged
30
+ * in tool handlers, the API and the browser. Callers hand `parseFormulaSpec` an
31
+ * already-parsed object; where that object came from is their problem.
32
+ */
33
+
34
+ import { evalExpression } from './table-formula';
35
+
36
+ export type FormulaValue = number | string | boolean | null;
37
+
38
+ export type VariableRole = 'constant' | 'input' | 'derived' | 'output';
39
+
40
+ export interface SpecVariable {
41
+ symbol: string;
42
+ name?: string;
43
+ /** Free text. Carried for display and review; not machine-checked (yet). */
44
+ unit?: string | null;
45
+ role: VariableRole;
46
+ /** Required for `constant`; optional default for `input`. */
47
+ value?: number | string | boolean;
48
+ /** Required for `derived` — an expression over other symbols. */
49
+ expression?: string;
50
+ note?: string;
51
+ }
52
+
53
+ export interface SpecExpression {
54
+ id: string;
55
+ expression: string;
56
+ /** Equation number in the source standard, for citation. */
57
+ equation?: string;
58
+ /** Symbol this expression produces, enabling unambiguous chaining. */
59
+ resultSymbol?: string;
60
+ unit?: string;
61
+ /**
62
+ * DISPLAY ONLY, and never parsed. `expression` is the single source of truth
63
+ * for what is computed; this is a parallel rendering for human eyes, so that
64
+ * a spec can be shown the way it appears in the standard. Nothing verifies
65
+ * the two agree — treat a mismatch as a documentation bug, and never reach
66
+ * for this when you mean `expression`.
67
+ */
68
+ latex?: string;
69
+ /**
70
+ * Set when the equation was NOT read off the source — supplied from memory,
71
+ * inferred, or reconstructed. It renders as a warning wherever the equation
72
+ * is shown or indexed, so a from-memory citation can never be mistaken for a
73
+ * transcribed one. The first cut carried this as an ad-hoc `derivedNotInSource`
74
+ * key, which the parser silently dropped — so the caveat vanished while the
75
+ * fabricated equation number went into the embedding as fact.
76
+ */
77
+ unverified?: string;
78
+ note?: string;
79
+ }
80
+
81
+ export interface SpecPiecewiseCase {
82
+ /** Condition expression; the first truthy case wins. */
83
+ when: string;
84
+ /** Id of the expression to evaluate when this case matches. */
85
+ use: string;
86
+ label?: string;
87
+ }
88
+
89
+ export interface SpecPiecewise {
90
+ id: string;
91
+ cases: SpecPiecewiseCase[];
92
+ /** Expression id used when no case matches. Absent means "that is an error". */
93
+ otherwise?: string;
94
+ resultSymbol?: string;
95
+ note?: string;
96
+ }
97
+
98
+ export interface SpecLookup {
99
+ id: string;
100
+ name?: string;
101
+ /** Variable symbols supplying the key, in no particular order. */
102
+ keys: string[];
103
+ /** The field on each row carrying the looked-up value. */
104
+ result: string;
105
+ rows: Array<Record<string, FormulaValue>>;
106
+ /** Declared legal values per key. Enables `checkLookupCoverage`. */
107
+ domains?: Record<string, FormulaValue[]>;
108
+ /**
109
+ * What an unmatched key means. Defaults to `error` — a missing row in a
110
+ * safety calculation is a gap in the standard, not a zero.
111
+ */
112
+ onMiss?: 'error' | 'null';
113
+ resultSymbol?: string;
114
+ }
115
+
116
+ export interface SpecClassification {
117
+ id: string;
118
+ domain: string[];
119
+ /** Rating → the criterion text from the source, for justification. */
120
+ criteria: Record<string, string>;
121
+ note?: string;
122
+ }
123
+
124
+ export interface SpecSource {
125
+ standard?: string;
126
+ part?: string;
127
+ sections?: string[];
128
+ tables?: string[];
129
+ edition?: string;
130
+ }
131
+
132
+ export interface FormulaSpec {
133
+ id: string;
134
+ name?: string;
135
+ source?: SpecSource;
136
+ unitSystem?: string;
137
+ notes?: Record<string, string>;
138
+ variables: SpecVariable[];
139
+ expressions: SpecExpression[];
140
+ piecewise: SpecPiecewise[];
141
+ lookups: SpecLookup[];
142
+ classifications: SpecClassification[];
143
+ }
144
+
145
+ export type ParseResult = { ok: true; spec: FormulaSpec } | { ok: false; errors: string[] };
146
+
147
+ const ROLES: VariableRole[] = ['constant', 'input', 'derived', 'output'];
148
+
149
+ function isObj(v: unknown): v is Record<string, unknown> {
150
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
151
+ }
152
+
153
+ /** A cell a lookup row may carry. Anything else (object, array, function,
154
+ * NaN) is rejected: it would flow out of `evaluateSpec` as the result and
155
+ * `toNum` would quietly turn it into 0. */
156
+ function isScalar(v: unknown): v is FormulaValue {
157
+ if (v === null) return true;
158
+ if (typeof v === 'string' || typeof v === 'boolean') return true;
159
+ return typeof v === 'number' && Number.isFinite(v);
160
+ }
161
+
162
+ function str(v: unknown): string | undefined {
163
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
164
+ }
165
+
166
+ /**
167
+ * Is this a syntactically valid expression? The validator used to check only
168
+ * that the string was non-empty, so a spec of pure punctuation validated
169
+ * clean and failed at evaluation time with no id context. Parsing here is
170
+ * what makes `parseFormulaSpec`'s "every problem found" claim true.
171
+ */
172
+ function syntaxError(src: string): string | null {
173
+ try {
174
+ evalExpression(src, () => null);
175
+ return null;
176
+ } catch (err) {
177
+ return err instanceof Error ? err.message : String(err);
178
+ }
179
+ }
180
+
181
+ /** Declared legal values per key. Rejects a non-array (a string has `.length`
182
+ * too, which used to reach `.map` and throw), non-scalar entries, and a key
183
+ * the lookup does not actually have. */
184
+ function parseDomains(
185
+ raw: unknown,
186
+ keys: string[],
187
+ at: string,
188
+ errors: string[],
189
+ ): Record<string, FormulaValue[]> | undefined {
190
+ if (raw === undefined || raw === null) return undefined;
191
+ if (!isObj(raw)) {
192
+ errors.push(`${at}.domains must be an object`);
193
+ return undefined;
194
+ }
195
+ const out: Record<string, FormulaValue[]> = {};
196
+ for (const [key, values] of Object.entries(raw)) {
197
+ if (!keys.includes(key)) {
198
+ errors.push(`${at}.domains names '${key}', which is not one of the lookup's keys`);
199
+ continue;
200
+ }
201
+ if (!Array.isArray(values)) {
202
+ errors.push(`${at}.domains['${key}'] must be an array`);
203
+ continue;
204
+ }
205
+ if (!values.every(isScalar)) {
206
+ errors.push(`${at}.domains['${key}'] may only contain numbers, strings, booleans or null`);
207
+ continue;
208
+ }
209
+ out[key] = values as FormulaValue[];
210
+ }
211
+ return Object.keys(out).length > 0 ? out : undefined;
212
+ }
213
+
214
+ /** `sections`/`tables` reached `.join` unvalidated, so `sections: '5.3'`
215
+ * validated clean and then threw inside `formulaToText` — hard-failing the
216
+ * extractor's ingest path for an otherwise-loadable spec. */
217
+ function parseSource(raw: unknown, errors: string[]): SpecSource | undefined {
218
+ if (raw === undefined || raw === null) return undefined;
219
+ if (!isObj(raw)) {
220
+ errors.push('spec.source must be an object');
221
+ return undefined;
222
+ }
223
+ const strList = (v: unknown, field: string): string[] | undefined => {
224
+ if (v === undefined || v === null) return undefined;
225
+ if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
226
+ errors.push(`spec.source.${field} must be an array of strings`);
227
+ return undefined;
228
+ }
229
+ return v as string[];
230
+ };
231
+ return {
232
+ standard: str(raw.standard),
233
+ part: str(raw.part),
234
+ edition: str(raw.edition),
235
+ sections: strList(raw.sections, 'sections'),
236
+ tables: strList(raw.tables, 'tables'),
237
+ };
238
+ }
239
+
240
+ /** Notes are free prose keyed by topic; anything non-string is dropped rather
241
+ * than rendered as `[object Object]` into the indexed text. */
242
+ function parseNotes(raw: unknown): Record<string, string> | undefined {
243
+ if (!isObj(raw)) return undefined;
244
+ const out: Record<string, string> = {};
245
+ for (const [key, value] of Object.entries(raw)) {
246
+ const text = str(value);
247
+ if (text) out[key] = text;
248
+ }
249
+ return Object.keys(out).length > 0 ? out : undefined;
250
+ }
251
+
252
+ /**
253
+ * Validate an already-parsed object into a FormulaSpec. Returns every problem
254
+ * found rather than throwing on the first, because these specs get transcribed
255
+ * from printed standards by hand and a reviewer wants the whole list.
256
+ */
257
+ export function parseFormulaSpec(input: unknown): ParseResult {
258
+ const errors: string[] = [];
259
+ /** A non-array where a list belongs used to be silently coerced to [], so a
260
+ * spec with `expressions: 'oops'` validated clean AND EMPTY. Report it. */
261
+ const asArray = (v: unknown, at: string): unknown[] => {
262
+ if (v === undefined || v === null) return [];
263
+ if (!Array.isArray(v)) {
264
+ errors.push(`${at} must be an array`);
265
+ return [];
266
+ }
267
+ return v;
268
+ };
269
+ if (!isObj(input)) return { ok: false, errors: ['spec must be an object'] };
270
+
271
+ const id = str(input.id);
272
+ if (!id) errors.push('spec.id is required');
273
+
274
+ const variables: SpecVariable[] = [];
275
+ const seenSymbols = new Set<string>();
276
+ for (const [i, raw] of asArray(input.variables, 'variables').entries()) {
277
+ const at = `variables[${i}]`;
278
+ if (!isObj(raw)) {
279
+ errors.push(`${at} must be an object`);
280
+ continue;
281
+ }
282
+ const symbol = str(raw.symbol);
283
+ const role = str(raw.role) as VariableRole | undefined;
284
+ if (!symbol) {
285
+ errors.push(`${at}.symbol is required`);
286
+ continue;
287
+ }
288
+ if (seenSymbols.has(symbol)) errors.push(`${at}: duplicate symbol '${symbol}'`);
289
+ seenSymbols.add(symbol);
290
+ if (!role || !ROLES.includes(role)) {
291
+ errors.push(`${at} '${symbol}': role must be one of ${ROLES.join(', ')}`);
292
+ continue;
293
+ }
294
+ const expression = str(raw.expression);
295
+ const hasValue = raw.value !== undefined && raw.value !== null;
296
+ if (role === 'constant' && !hasValue) {
297
+ errors.push(`${at} '${symbol}': a constant needs a value`);
298
+ }
299
+ if (role === 'derived' && !expression) {
300
+ errors.push(`${at} '${symbol}': a derived variable needs an expression`);
301
+ }
302
+ if (expression) {
303
+ const bad = syntaxError(expression);
304
+ if (bad) errors.push(`${at} '${symbol}': expression does not parse — ${bad}`);
305
+ }
306
+ variables.push({
307
+ symbol,
308
+ name: str(raw.name),
309
+ unit: typeof raw.unit === 'string' ? raw.unit : null,
310
+ role,
311
+ value: hasValue ? (raw.value as number | string | boolean) : undefined,
312
+ expression,
313
+ note: str(raw.note),
314
+ });
315
+ }
316
+
317
+ // Expressions, piecewise and lookups share one id namespace: a piecewise case
318
+ // and an evaluation target both address them by bare id.
319
+ const ids = new Set<string>();
320
+ const claimId = (candidate: string | undefined, at: string): string => {
321
+ if (!candidate) {
322
+ errors.push(`${at}.id is required`);
323
+ return '';
324
+ }
325
+ if (ids.has(candidate)) errors.push(`${at}: duplicate id '${candidate}'`);
326
+ ids.add(candidate);
327
+ return candidate;
328
+ };
329
+
330
+ const expressions: SpecExpression[] = [];
331
+ for (const [i, raw] of asArray(input.expressions, 'expressions').entries()) {
332
+ const at = `expressions[${i}]`;
333
+ if (!isObj(raw)) {
334
+ errors.push(`${at} must be an object`);
335
+ continue;
336
+ }
337
+ const exprId = claimId(str(raw.id), at);
338
+ const expression = str(raw.expression);
339
+ if (!expression) errors.push(`${at} '${exprId}': expression is required`);
340
+ else {
341
+ const bad = syntaxError(expression);
342
+ if (bad) errors.push(`${at} '${exprId}': expression does not parse — ${bad}`);
343
+ }
344
+ expressions.push({
345
+ id: exprId,
346
+ expression: expression ?? '',
347
+ equation: str(raw.equation),
348
+ resultSymbol: str(raw.resultSymbol),
349
+ unit: str(raw.unit),
350
+ latex: str(raw.latex),
351
+ unverified: str(raw.unverified),
352
+ note: str(raw.note),
353
+ });
354
+ }
355
+
356
+ const piecewise: SpecPiecewise[] = [];
357
+ for (const [i, raw] of asArray(input.piecewise, 'piecewise').entries()) {
358
+ const at = `piecewise[${i}]`;
359
+ if (!isObj(raw)) {
360
+ errors.push(`${at} must be an object`);
361
+ continue;
362
+ }
363
+ const pwId = claimId(str(raw.id), at);
364
+ const cases: SpecPiecewiseCase[] = [];
365
+ for (const [j, rawCase] of asArray(raw.cases, `${at}.cases`).entries()) {
366
+ if (!isObj(rawCase)) {
367
+ errors.push(`${at}.cases[${j}] must be an object`);
368
+ continue;
369
+ }
370
+ const when = str(rawCase.when);
371
+ const use = str(rawCase.use);
372
+ if (!when) errors.push(`${at}.cases[${j}]: when is required`);
373
+ else {
374
+ const bad = syntaxError(when);
375
+ if (bad) errors.push(`${at}.cases[${j}]: when does not parse — ${bad}`);
376
+ }
377
+ if (!use) errors.push(`${at}.cases[${j}]: use is required`);
378
+ if (when && use) cases.push({ when, use, label: str(rawCase.label) });
379
+ }
380
+ if (cases.length === 0) errors.push(`${at} '${pwId}': needs at least one case`);
381
+ piecewise.push({
382
+ id: pwId,
383
+ cases,
384
+ otherwise: str(raw.otherwise),
385
+ resultSymbol: str(raw.resultSymbol),
386
+ note: str(raw.note),
387
+ });
388
+ }
389
+
390
+ const lookups: SpecLookup[] = [];
391
+ for (const [i, raw] of asArray(input.lookups, 'lookups').entries()) {
392
+ const at = `lookups[${i}]`;
393
+ if (!isObj(raw)) {
394
+ errors.push(`${at} must be an object`);
395
+ continue;
396
+ }
397
+ const lkId = claimId(str(raw.id), at);
398
+ const keys = asArray(raw.keys, `${at}.keys`).filter((k): k is string => typeof k === 'string');
399
+ const result = str(raw.result);
400
+ if (keys.length === 0) errors.push(`${at} '${lkId}': needs at least one key`);
401
+ if (!result) errors.push(`${at} '${lkId}': result field name is required`);
402
+ const rows: Array<Record<string, FormulaValue>> = [];
403
+ for (const [j, rawRow] of asArray(raw.rows, `${at}.rows`).entries()) {
404
+ if (!isObj(rawRow)) {
405
+ errors.push(`${at}.rows[${j}] must be an object`);
406
+ continue;
407
+ }
408
+ // Object.hasOwn, not `!== undefined`: a field named `toString` or
409
+ // `constructor` inherits from the prototype, so the loose check passed
410
+ // and a FUNCTION flowed out as the looked-up value.
411
+ for (const key of keys) {
412
+ if (!Object.hasOwn(rawRow, key)) {
413
+ errors.push(`${at}.rows[${j}] is missing key '${key}'`);
414
+ } else if (!isScalar(rawRow[key])) {
415
+ errors.push(`${at}.rows[${j}] key '${key}' must be a number, string, boolean or null`);
416
+ }
417
+ }
418
+ if (result) {
419
+ if (!Object.hasOwn(rawRow, result)) {
420
+ errors.push(`${at}.rows[${j}] is missing result '${result}'`);
421
+ } else if (!isScalar(rawRow[result])) {
422
+ // Without this, `fact_di: {"value": 0.25}` validated clean and then
423
+ // read as 0 in arithmetic — a silent zero adjustment.
424
+ errors.push(
425
+ `${at}.rows[${j}] result '${result}' must be a number, string, boolean or null`,
426
+ );
427
+ }
428
+ }
429
+ rows.push(rawRow as Record<string, FormulaValue>);
430
+ }
431
+ if (rows.length === 0) errors.push(`${at} '${lkId}': needs at least one row`);
432
+ const onMiss = str(raw.onMiss);
433
+ if (onMiss && onMiss !== 'error' && onMiss !== 'null') {
434
+ errors.push(`${at} '${lkId}': onMiss must be 'error' or 'null'`);
435
+ }
436
+ lookups.push({
437
+ id: lkId,
438
+ name: str(raw.name),
439
+ keys,
440
+ result: result ?? '',
441
+ rows,
442
+ domains: parseDomains(raw.domains, keys, at, errors),
443
+ onMiss: onMiss === 'null' ? 'null' : 'error',
444
+ resultSymbol: str(raw.resultSymbol),
445
+ });
446
+ }
447
+
448
+ const classifications: SpecClassification[] = [];
449
+ for (const [i, raw] of asArray(input.classifications, 'classifications').entries()) {
450
+ const at = `classifications[${i}]`;
451
+ if (!isObj(raw)) {
452
+ errors.push(`${at} must be an object`);
453
+ continue;
454
+ }
455
+ const clId = claimId(str(raw.id), at);
456
+ const domain = asArray(raw.domain, `${at}.domain`).filter(
457
+ (d): d is string => typeof d === 'string',
458
+ );
459
+ if (domain.length === 0) errors.push(`${at} '${clId}': domain is required`);
460
+ const criteria = isObj(raw.criteria) ? (raw.criteria as Record<string, string>) : {};
461
+ for (const value of domain) {
462
+ // Own-property again: a domain entry of 'toString' would otherwise find
463
+ // an inherited function and render it into the indexed text.
464
+ if (!Object.hasOwn(criteria, value) || !str(criteria[value])) {
465
+ errors.push(`${at} '${clId}': no criterion for '${value}'`);
466
+ }
467
+ }
468
+ classifications.push({ id: clId, domain, criteria, note: str(raw.note) });
469
+ }
470
+
471
+ // Cross-references resolve only once every id is known.
472
+ for (const pw of piecewise) {
473
+ for (const c of pw.cases) {
474
+ if (!ids.has(c.use)) errors.push(`piecewise '${pw.id}': case uses unknown id '${c.use}'`);
475
+ }
476
+ if (pw.otherwise && !ids.has(pw.otherwise)) {
477
+ errors.push(`piecewise '${pw.id}': otherwise uses unknown id '${pw.otherwise}'`);
478
+ }
479
+ }
480
+
481
+ const source = parseSource(input.source, errors);
482
+ const notes = parseNotes(input.notes);
483
+
484
+ if (errors.length > 0) return { ok: false, errors };
485
+ return {
486
+ ok: true,
487
+ spec: {
488
+ id: id!,
489
+ name: str(input.name),
490
+ source,
491
+ unitSystem: str(input.unitSystem),
492
+ notes,
493
+ variables,
494
+ expressions,
495
+ piecewise,
496
+ lookups,
497
+ classifications,
498
+ },
499
+ };
500
+ }
501
+
502
+ export interface CoverageGap {
503
+ lookupId: string;
504
+ /** A key combination in the declared domains with no matching row. */
505
+ key: Record<string, FormulaValue>;
506
+ /** Set instead of `key` when the table was too large to check — a silent
507
+ * "no gaps" on an unchecked table would be a lie. */
508
+ skipped?: string;
509
+ }
510
+
511
+ /**
512
+ * Report key combinations a lookup declares as legal but has no row for.
513
+ *
514
+ * This is the payoff for storing tables as data. API RP 581 Table 5.6 specifies
515
+ * six of the nine detection/isolation combinations; the other three are simply
516
+ * absent from the printed table. Encoded as a nested IF() that gap is invisible
517
+ * until it silently yields a zero adjustment on a live assessment. Encoded as
518
+ * rows with declared domains, it is a list you can put in front of an engineer.
519
+ *
520
+ * Not an error — an incomplete table is a fact about the source, not a
521
+ * malformed spec — so this is reported separately from `parseFormulaSpec`.
522
+ */
523
+ /**
524
+ * Ceiling on the cartesian product a single lookup may expand to.
525
+ *
526
+ * Without it this function is a remote kill switch: `domains` of 6 keys × 20
527
+ * values is 64M combinations, each materialised as an object, and Node dies
528
+ * with an OOM that NO caller can catch. Coverage runs on every read of every
529
+ * formula, so one stored spec would brick the brain permanently. Real tables
530
+ * are tens of rows; anything past this is a malformed or hostile spec.
531
+ */
532
+ const MAX_COVERAGE_COMBINATIONS = 10_000;
533
+
534
+ export function checkLookupCoverage(spec: FormulaSpec): CoverageGap[] {
535
+ const gaps: CoverageGap[] = [];
536
+ for (const lookup of spec.lookups ?? []) {
537
+ if (!lookup.domains) continue;
538
+ const keys = lookup.keys.filter(
539
+ (k) => Array.isArray(lookup.domains?.[k]) && lookup.domains[k]!.length > 0,
540
+ );
541
+ if (keys.length !== lookup.keys.length) continue; // partial domains: can't be exhaustive
542
+
543
+ // Size the product BEFORE building it.
544
+ let total = 1;
545
+ for (const key of keys) total *= lookup.domains[key]!.length;
546
+ if (total > MAX_COVERAGE_COMBINATIONS) {
547
+ gaps.push({
548
+ lookupId: lookup.id,
549
+ key: {},
550
+ skipped: `declares ${total} key combinations, above the ${MAX_COVERAGE_COMBINATIONS} coverage limit — not checked`,
551
+ });
552
+ continue;
553
+ }
554
+
555
+ let combos: Array<Record<string, FormulaValue>> = [{}];
556
+ for (const key of keys) {
557
+ const values = lookup.domains[key]!;
558
+ combos = combos.flatMap((base) => values.map((v) => ({ ...base, [key]: v })));
559
+ }
560
+ for (const combo of combos) {
561
+ const hit = lookup.rows.some((row) => keys.every((k) => row[k] === combo[k]));
562
+ if (!hit) gaps.push({ lookupId: lookup.id, key: combo });
563
+ }
564
+ }
565
+ return gaps;
566
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { categoryLabel, moodDisplay, normalizeEntryDate } from './journal-options';
3
+
4
+ describe('normalizeEntryDate', () => {
5
+ it('passes through a full ISO timestamp (canonicalised)', () => {
6
+ const out = normalizeEntryDate('2025-12-25T08:30:00.000Z');
7
+ expect(out).toBe('2025-12-25T08:30:00.000Z');
8
+ });
9
+
10
+ it('accepts a bare date and yields a valid ISO string', () => {
11
+ const out = normalizeEntryDate('2025-12-25');
12
+ expect(out).not.toBeNull();
13
+ expect(out).toMatch(/^2025-12-25T/);
14
+ // round-trips through Date without throwing
15
+ expect(Number.isNaN(Date.parse(out!))).toBe(false);
16
+ });
17
+
18
+ it('rejects free-text that is not a date — the cast-poison guard', () => {
19
+ expect(normalizeEntryDate('next Tuesday')).toBeNull();
20
+ expect(normalizeEntryDate('tomorrow')).toBeNull();
21
+ expect(normalizeEntryDate('soon-ish')).toBeNull();
22
+ expect(normalizeEntryDate('2026-13-99')).toBeNull(); // out-of-range → invalid
23
+ });
24
+
25
+ it('treats empty / whitespace / non-string as "no date"', () => {
26
+ expect(normalizeEntryDate('')).toBeNull();
27
+ expect(normalizeEntryDate(' ')).toBeNull();
28
+ expect(normalizeEntryDate(null)).toBeNull();
29
+ expect(normalizeEntryDate(undefined)).toBeNull();
30
+ // @ts-expect-error — guard against a non-string slipping through at runtime
31
+ expect(normalizeEntryDate(123)).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe('moodDisplay', () => {
36
+ it('maps a known mood key to emoji + label', () => {
37
+ expect(moodDisplay('grateful')).toEqual({ emoji: '🙏', label: 'Grateful' });
38
+ });
39
+ it('tolerates an unknown/free-text mood (no emoji, raw label)', () => {
40
+ expect(moodDisplay('zonked')).toEqual({ emoji: '', label: 'zonked' });
41
+ });
42
+ it('returns null for no mood', () => {
43
+ expect(moodDisplay(null)).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe('categoryLabel', () => {
48
+ it('maps a known category key to its label', () => {
49
+ expect(categoryLabel('faith')).toBe('Faith');
50
+ });
51
+ it('title-cases an unknown/free-text category', () => {
52
+ expect(categoryLabel('hobbies')).toBe('Hobbies');
53
+ });
54
+ it('returns null for no category', () => {
55
+ expect(categoryLabel(null)).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Browser-safe leaf for Journal option lists (moods + categories).
3
+ *
4
+ * These constants are needed both server-side (CRUD, extractor framing, the
5
+ * identity-context distiller) and client-side (the /journal editor + filters).
6
+ * They live in their own module — with NO `@mantle/db` import — so a client
7
+ * component can pull them in without dragging `postgres` into the browser
8
+ * bundle. Same pattern as `contacts-format.ts`. `journal.ts` re-exports these.
9
+ */
10
+
11
+ /** Curated mood palette (emoji + label). Stored as the bare key string in
12
+ * `data.mood`; the UI maps key → emoji + label. Free text is tolerated on
13
+ * read, but the picker offers these. */
14
+ export const MOODS = [
15
+ { key: 'happy', label: 'Happy', emoji: '😀' },
16
+ { key: 'grateful', label: 'Grateful', emoji: '🙏' },
17
+ { key: 'calm', label: 'Calm', emoji: '😌' },
18
+ { key: 'excited', label: 'Excited', emoji: '🤩' },
19
+ { key: 'hopeful', label: 'Hopeful', emoji: '🌱' },
20
+ { key: 'reflective', label: 'Reflective', emoji: '🤔' },
21
+ { key: 'tired', label: 'Tired', emoji: '😮‍💨' },
22
+ { key: 'anxious', label: 'Anxious', emoji: '😟' },
23
+ { key: 'sad', label: 'Sad', emoji: '😔' },
24
+ { key: 'angry', label: 'Angry', emoji: '😠' },
25
+ ] as const;
26
+
27
+ export type MoodKey = (typeof MOODS)[number]['key'];
28
+ export const MOOD_KEYS: readonly string[] = MOODS.map((m) => m.key);
29
+
30
+ /** Life areas the entry speaks to. Drives the identity block's grouping
31
+ * ("## Work", "## Faith", …) and the list filter. */
32
+ export const CATEGORIES = [
33
+ { key: 'identity', label: 'Identity' },
34
+ { key: 'work', label: 'Work' },
35
+ { key: 'family', label: 'Family' },
36
+ { key: 'relationships', label: 'Relationships' },
37
+ { key: 'faith', label: 'Faith' },
38
+ { key: 'health', label: 'Health' },
39
+ { key: 'emotion', label: 'Emotion' },
40
+ { key: 'goal', label: 'Goal' },
41
+ { key: 'reflection', label: 'Reflection' },
42
+ ] as const;
43
+
44
+ export type CategoryKey = (typeof CATEGORIES)[number]['key'];
45
+ export const CATEGORY_KEYS: readonly string[] = CATEGORIES.map((c) => c.key);
46
+
47
+ /** Mood key → display (emoji + label), tolerant of free-text/unknown values. */
48
+ export function moodDisplay(key: string | null): { emoji: string; label: string } | null {
49
+ if (!key) return null;
50
+ const found = MOODS.find((m) => m.key === key);
51
+ if (found) return { emoji: found.emoji, label: found.label };
52
+ return { emoji: '', label: key };
53
+ }
54
+
55
+ /** Category key → human label, tolerant of free-text/unknown values. */
56
+ export function categoryLabel(key: string | null): string | null {
57
+ if (!key) return null;
58
+ const found = CATEGORIES.find((c) => c.key === key);
59
+ if (found) return found.label;
60
+ return key.charAt(0).toUpperCase() + key.slice(1);
61
+ }
62
+
63
+ /**
64
+ * Normalise a user/agent-supplied entry date to a canonical ISO-8601 string,
65
+ * or return null if it isn't a real date. Stored `entry_date` is later cast to
66
+ * `timestamptz` in the list/identity sort, so an unparseable value (e.g. the
67
+ * agent passing "next Tuesday") MUST be rejected here — otherwise it poisons
68
+ * the ORDER BY and breaks the whole list. `''`/whitespace → null (no date).
69
+ */
70
+ export function normalizeEntryDate(value: string | null | undefined): string | null {
71
+ if (typeof value !== 'string') return null;
72
+ const trimmed = value.trim();
73
+ if (!trimmed) return null;
74
+ const ms = Date.parse(trimmed);
75
+ if (Number.isNaN(ms)) return null;
76
+ return new Date(ms).toISOString();
77
+ }