@dlovans/tenet-core 0.2.1 → 0.4.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.
@@ -15,9 +15,12 @@ export declare function run(schema: TenetSchema | string, effectiveDate?: Date |
15
15
  * Verify that a completed document was correctly derived from a base schema.
16
16
  * Simulates the user's journey by iteratively copying visible field values and re-running.
17
17
  *
18
+ * Returns a structured result with all issues found (not just the first).
19
+ * Crash-safe: catches any unexpected error and returns it as an internal_error issue.
20
+ *
18
21
  * @param newSchema - The completed/submitted schema
19
22
  * @param oldSchema - The original base schema
20
23
  * @param maxIterations - Maximum replay iterations (default: 100)
21
- * @returns Whether the transformation was valid
24
+ * @returns Structured verification result
22
25
  */
23
26
  export declare function verify(newSchema: TenetSchema | string, oldSchema: TenetSchema | string, maxIterations?: number): TenetVerifyResult;
@@ -80,7 +80,7 @@ function setDefinitionValue(state, key, value, ruleId) {
80
80
  // Cycle detection
81
81
  const prevRule = state.fieldsSet.get(key);
82
82
  if (prevRule && prevRule !== ruleId) {
83
- addError(state, key, ruleId, `Potential cycle: field '${key}' set by rule '${prevRule}' and again by rule '${ruleId}'`);
83
+ addError(state, key, ruleId, 'cycle_detected', `Potential cycle: field '${key}' set by rule '${prevRule}' and again by rule '${ruleId}'`);
84
84
  }
85
85
  state.fieldsSet.set(key, ruleId);
86
86
  const def = state.schema.definitions[key];
@@ -118,7 +118,7 @@ function applyAction(state, action, ruleId, lawRef) {
118
118
  }
119
119
  // Emit error if specified
120
120
  if (action.error_msg) {
121
- addError(state, '', ruleId, action.error_msg, lawRef);
121
+ addError(state, '', ruleId, 'runtime_warning', action.error_msg, lawRef);
122
122
  }
123
123
  }
124
124
  /**
@@ -152,13 +152,23 @@ function computeDerived(state) {
152
152
  }
153
153
  // Evaluate the expression
154
154
  const value = resolve(derivedDef.eval, state);
155
- // Store the computed value as a definition (readonly)
156
- state.schema.definitions[name] = {
157
- type: inferType(value),
158
- value,
159
- readonly: true,
160
- visible: true,
161
- };
155
+ // Preserve existing definition metadata if present
156
+ const existing = state.schema.definitions[name];
157
+ if (existing) {
158
+ existing.value = value;
159
+ existing.readonly = true;
160
+ if (existing.visible === undefined) {
161
+ existing.visible = true;
162
+ }
163
+ }
164
+ else {
165
+ state.schema.definitions[name] = {
166
+ type: inferType(value),
167
+ value,
168
+ readonly: true,
169
+ visible: true,
170
+ };
171
+ }
162
172
  }
163
173
  }
164
174
  /**
@@ -190,6 +200,7 @@ export function run(schema, effectiveDate = new Date()) {
190
200
  effectiveDate: date,
191
201
  fieldsSet: new Map(),
192
202
  errors: [],
203
+ derivedInProgress: new Set(),
193
204
  };
194
205
  // 1. Select temporal branch and prune inactive rules
195
206
  applyTemporalRouting(state);
@@ -227,25 +238,29 @@ function getVisibleEditableFields(schema) {
227
238
  return result;
228
239
  }
229
240
  /**
230
- * Count visible fields in a schema.
241
+ * Get a sorted, comma-joined string of visible field IDs for convergence detection.
231
242
  */
232
- function countVisibleFields(schema) {
233
- let count = 0;
234
- for (const def of Object.values(schema.definitions)) {
235
- if (def?.visible) {
236
- count++;
237
- }
238
- }
239
- return count;
243
+ function getVisibleFieldIds(schema) {
244
+ return Object.entries(schema.definitions)
245
+ .filter(([, def]) => def?.visible)
246
+ .map(([id]) => id)
247
+ .sort()
248
+ .join(',');
240
249
  }
241
250
  /**
242
251
  * Validate that the final state matches expected values.
252
+ * Collects ALL issues instead of bailing on the first — the UI needs the complete picture.
243
253
  */
244
254
  function validateFinalState(newSchema, resultSchema) {
255
+ const issues = [];
245
256
  // Check for unknown/injected fields in newSchema that don't exist in result
246
257
  for (const id of Object.keys(newSchema.definitions)) {
247
258
  if (!(id in resultSchema.definitions)) {
248
- return [false, `Unknown field '${id}' not in schema`];
259
+ issues.push({
260
+ code: 'unknown_field',
261
+ field_id: id,
262
+ message: `Field '${id}' does not exist in the schema`,
263
+ });
249
264
  }
250
265
  }
251
266
  // Compare computed (readonly) values
@@ -255,49 +270,85 @@ function validateFinalState(newSchema, resultSchema) {
255
270
  }
256
271
  const newDef = newSchema.definitions[id];
257
272
  if (!newDef) {
258
- return [false, `Computed field '${id}' missing in submitted document`];
273
+ issues.push({
274
+ code: 'computed_mismatch',
275
+ field_id: id,
276
+ message: `Computed field '${id}' is missing from the submitted document`,
277
+ expected: resultDef.value,
278
+ });
279
+ continue;
259
280
  }
260
281
  if (!compareEqual(newDef.value, resultDef.value)) {
261
- return [false, `Computed field '${id}' mismatch: claimed ${JSON.stringify(newDef.value)}, expected ${JSON.stringify(resultDef.value)}`];
282
+ issues.push({
283
+ code: 'computed_mismatch',
284
+ field_id: id,
285
+ message: `Computed field '${id}' was modified`,
286
+ expected: resultDef.value,
287
+ claimed: newDef.value,
288
+ });
262
289
  }
263
290
  }
264
291
  // Verify attestations are fulfilled
265
292
  if (resultSchema.attestations) {
266
293
  for (const [id, resultAtt] of Object.entries(resultSchema.attestations)) {
267
- if (!resultAtt) {
294
+ if (!resultAtt?.required) {
268
295
  continue;
269
296
  }
270
297
  const newAtt = newSchema.attestations?.[id];
271
298
  if (!newAtt) {
272
299
  continue;
273
300
  }
274
- if (resultAtt.required) {
275
- if (!newAtt.signed) {
276
- return [false, `Required attestation '${id}' not signed`];
277
- }
278
- if (!newAtt.evidence?.provider_audit_id) {
279
- return [false, `Attestation '${id}' missing evidence`];
280
- }
281
- if (!newAtt.evidence?.timestamp) {
282
- return [false, `Attestation '${id}' missing timestamp`];
283
- }
301
+ if (!newAtt.signed) {
302
+ issues.push({
303
+ code: 'attestation_unsigned',
304
+ field_id: id,
305
+ message: `Required attestation '${id}' has not been signed`,
306
+ });
307
+ continue; // No point checking evidence if unsigned
308
+ }
309
+ if (!newAtt.evidence?.provider_audit_id) {
310
+ issues.push({
311
+ code: 'attestation_no_evidence',
312
+ field_id: id,
313
+ message: `Attestation '${id}' is signed but missing proof of signing`,
314
+ });
315
+ }
316
+ if (!newAtt.evidence?.timestamp) {
317
+ issues.push({
318
+ code: 'attestation_no_timestamp',
319
+ field_id: id,
320
+ message: `Attestation '${id}' is signed but missing a timestamp`,
321
+ });
284
322
  }
285
323
  }
286
324
  }
287
325
  // Verify status matches
288
326
  if (newSchema.status !== resultSchema.status) {
289
- return [false, `Status mismatch: claimed ${newSchema.status}, expected ${resultSchema.status}`];
327
+ issues.push({
328
+ code: 'status_mismatch',
329
+ message: 'The document status does not match what was computed',
330
+ expected: resultSchema.status,
331
+ claimed: newSchema.status,
332
+ });
290
333
  }
291
- return [true, undefined];
334
+ return {
335
+ valid: issues.length === 0,
336
+ status: resultSchema.status,
337
+ issues: issues.length > 0 ? issues : undefined,
338
+ schema: resultSchema,
339
+ };
292
340
  }
293
341
  /**
294
342
  * Verify that a completed document was correctly derived from a base schema.
295
343
  * Simulates the user's journey by iteratively copying visible field values and re-running.
296
344
  *
345
+ * Returns a structured result with all issues found (not just the first).
346
+ * Crash-safe: catches any unexpected error and returns it as an internal_error issue.
347
+ *
297
348
  * @param newSchema - The completed/submitted schema
298
349
  * @param oldSchema - The original base schema
299
350
  * @param maxIterations - Maximum replay iterations (default: 100)
300
- * @returns Whether the transformation was valid
351
+ * @returns Structured verification result
301
352
  */
302
353
  export function verify(newSchema, oldSchema, maxIterations = 100) {
303
354
  try {
@@ -318,7 +369,7 @@ export function verify(newSchema, oldSchema, maxIterations = 100) {
318
369
  }
319
370
  // Start with base schema
320
371
  let currentSchema = parsedOldSchema;
321
- let previousVisibleCount = -1;
372
+ let previousVisibleIds = '';
322
373
  for (let iteration = 0; iteration < maxIterations; iteration++) {
323
374
  // Count visible editable fields before copying
324
375
  const visibleEditable = getVisibleEditableFields(currentSchema);
@@ -345,23 +396,42 @@ export function verify(newSchema, oldSchema, maxIterations = 100) {
345
396
  // Run the schema
346
397
  const runResult = run(currentSchema, effectiveDate);
347
398
  if (runResult.error) {
348
- return { valid: false, error: `Run failed (iteration ${iteration}): ${runResult.error}` };
399
+ return {
400
+ valid: false,
401
+ issues: [{
402
+ code: 'internal_error',
403
+ message: `VM run failed at iteration ${iteration}`,
404
+ }],
405
+ error: `Run failed (iteration ${iteration}): ${runResult.error}`,
406
+ };
349
407
  }
350
408
  const resultSchema = runResult.result;
351
- // Count visible fields after run
352
- const currentVisibleCount = countVisibleFields(resultSchema);
353
- // Check for convergence
354
- if (currentVisibleCount === previousVisibleCount) {
355
- // Converged - now validate the final state
356
- const [valid, error] = validateFinalState(parsedNewSchema, resultSchema);
357
- return { valid, error };
409
+ // Get visible field IDs after run
410
+ const currentVisibleIds = getVisibleFieldIds(resultSchema);
411
+ // Check for convergence using set comparison
412
+ if (currentVisibleIds === previousVisibleIds) {
413
+ // Converged - now validate the final state and return full result
414
+ return validateFinalState(parsedNewSchema, resultSchema);
358
415
  }
359
- previousVisibleCount = currentVisibleCount;
416
+ previousVisibleIds = currentVisibleIds;
360
417
  currentSchema = resultSchema;
361
418
  }
362
- return { valid: false, error: `Verification did not converge after ${maxIterations} iterations` };
419
+ return {
420
+ valid: false,
421
+ issues: [{
422
+ code: 'convergence_failed',
423
+ message: `Document did not converge after ${maxIterations} iterations`,
424
+ }],
425
+ };
363
426
  }
364
427
  catch (error) {
365
- return { valid: false, error: String(error) };
428
+ return {
429
+ valid: false,
430
+ issues: [{
431
+ code: 'internal_error',
432
+ message: `Unexpected error during verification`,
433
+ }],
434
+ error: String(error),
435
+ };
366
436
  }
367
437
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Static schema linter for Tenet schemas.
3
+ * Analyzes schemas without executing them, catching structural errors,
4
+ * type mismatches, undefined references, and circular dependencies.
5
+ */
6
+ import type { TenetSchema } from './types.js';
7
+ export type LintSeverity = 'error' | 'warning';
8
+ export interface LintIssue {
9
+ severity: LintSeverity;
10
+ code: string;
11
+ message: string;
12
+ path?: string;
13
+ field_id?: string;
14
+ rule_id?: string;
15
+ }
16
+ export interface LintResult {
17
+ valid: boolean;
18
+ issues: LintIssue[];
19
+ }
20
+ export declare function lint(schema: TenetSchema | string): LintResult;