@ontrails/trails 1.0.0-beta.41 → 1.0.0-beta.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.
@@ -9,6 +9,12 @@ import { InternalError, Result, ValidationError } from '@ontrails/core';
9
9
  import type { Result as TrailsResult } from '@ontrails/core';
10
10
  import { regradeReportOutput } from '@ontrails/regrade';
11
11
  import type { RegradeReport } from '@ontrails/regrade';
12
+ import {
13
+ getGovernedVocabularyTransition,
14
+ governedVocabularyHistoryProvenanceSchema,
15
+ listGovernedVocabularyTransitions,
16
+ } from '@ontrails/warden';
17
+ import type { GovernedVocabularyHistoryProvenance } from '@ontrails/warden';
12
18
  import { createHash } from 'node:crypto';
13
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
20
  import { dirname, isAbsolute, join } from 'node:path';
@@ -61,6 +67,7 @@ const regradeHistoryRunSchema = z
61
67
  .string()
62
68
  .min(1)
63
69
  .describe('Canonical content hash of the resolved plan body'),
70
+ provenance: governedVocabularyHistoryProvenanceSchema.optional(),
64
71
  report: regradeReportOutput.describe('Applied report recorded by the run'),
65
72
  })
66
73
  .strict();
@@ -85,6 +92,7 @@ interface RegradeHistoryRun {
85
92
  readonly lockHashAtRun: string;
86
93
  readonly plan: RegradePlanArtifact;
87
94
  readonly planContentHash: string;
95
+ readonly provenance?: GovernedVocabularyHistoryProvenance;
88
96
  readonly report: RegradeReport;
89
97
  }
90
98
 
@@ -97,7 +105,9 @@ export interface RegradeHistoryArtifact {
97
105
  }
98
106
 
99
107
  export interface RegradeHistorySummary {
108
+ readonly id: string;
100
109
  readonly path: string;
110
+ readonly provenance?: GovernedVocabularyHistoryProvenance;
101
111
  readonly schemaVersion: number;
102
112
  readonly status: 'applied' | 'replay';
103
113
  }
@@ -195,6 +205,117 @@ export const mintTransitionId = (
195
205
  .digest('hex')
196
206
  .slice(0, 12);
197
207
 
208
+ const targetIncludesPlanTarget = (
209
+ transition: NonNullable<ReturnType<typeof getGovernedVocabularyTransition>>,
210
+ to: string
211
+ ): boolean =>
212
+ transition.target.kind === 'single'
213
+ ? transition.target.to === to
214
+ : transition.target.options.some((option) => option.to === to);
215
+
216
+ const governedTransitionForPlan = (
217
+ plan: Extract<RegradePlanBody, { kind: 'vocabulary' }>
218
+ ) => {
219
+ const idTransition =
220
+ plan.id === undefined
221
+ ? undefined
222
+ : getGovernedVocabularyTransition(plan.id);
223
+ return (
224
+ idTransition ??
225
+ listGovernedVocabularyTransitions().find(
226
+ (transition) => transition.from === plan.from
227
+ )
228
+ );
229
+ };
230
+
231
+ export const validateGovernedRegradePlan = (
232
+ artifact: RegradePlanArtifact
233
+ ): TrailsResult<void, ValidationError> => {
234
+ const { plan } = artifact;
235
+ if (plan.kind !== 'vocabulary') {
236
+ return Result.ok();
237
+ }
238
+ const transition = governedTransitionForPlan(plan);
239
+ if (transition === undefined) {
240
+ return Result.ok();
241
+ }
242
+ if (
243
+ transition.from === plan.from &&
244
+ targetIncludesPlanTarget(transition, plan.to)
245
+ ) {
246
+ return Result.ok();
247
+ }
248
+ return Result.err(
249
+ new ValidationError(
250
+ 'Governed Regrade plan does not match its registry transition.',
251
+ {
252
+ context: {
253
+ plan: { from: plan.from, id: plan.id, to: plan.to },
254
+ registry: { from: transition.from, id: transition.id },
255
+ },
256
+ }
257
+ )
258
+ );
259
+ };
260
+
261
+ const governedProvenanceForRun = (params: {
262
+ readonly artifact: RegradePlanArtifact;
263
+ readonly completionReport: RegradeReport;
264
+ readonly planContentHash: string;
265
+ readonly report: RegradeReport;
266
+ }): TrailsResult<
267
+ GovernedVocabularyHistoryProvenance | undefined,
268
+ ValidationError
269
+ > => {
270
+ const { plan } = params.artifact;
271
+ const validation = validateGovernedRegradePlan(params.artifact);
272
+ if (validation.isErr()) {
273
+ return validation;
274
+ }
275
+ if (plan.kind !== 'vocabulary') {
276
+ return Result.ok();
277
+ }
278
+ const transition = governedTransitionForPlan(plan);
279
+ if (transition === undefined) {
280
+ return Result.ok();
281
+ }
282
+
283
+ const reviewPending = params.report.review;
284
+ return Result.ok({
285
+ disposition: reviewPending > 0 ? 'review-follow-up' : 'applied-clean',
286
+ kind: 'governed-vocabulary',
287
+ planContentHash: params.planContentHash,
288
+ reviewPending,
289
+ safeApplied: params.report.apply?.applied ?? params.report.rewritten,
290
+ sourceHashAfter: regradeSourceHash(params.completionReport),
291
+ sourceHashBefore: regradeSourceHash(params.report),
292
+ transitionId: transition.id,
293
+ });
294
+ };
295
+
296
+ const governedProvenanceMatchesRun = (params: {
297
+ readonly expected: GovernedVocabularyHistoryProvenance | undefined;
298
+ readonly run: RegradeHistoryRun;
299
+ }): boolean => {
300
+ const { expected, run } = params;
301
+ const actual = run.provenance;
302
+ if (actual === undefined || expected === undefined) {
303
+ return actual === expected;
304
+ }
305
+ return (
306
+ actual.disposition === expected.disposition &&
307
+ actual.kind === expected.kind &&
308
+ actual.planContentHash === expected.planContentHash &&
309
+ actual.reviewPending === expected.reviewPending &&
310
+ actual.safeApplied === expected.safeApplied &&
311
+ (regradeSourceHashMatches(actual.sourceHashBefore, run.report) ||
312
+ run[rawReportHash] === actual.sourceHashBefore) &&
313
+ (regradeSourceHashMatches(actual.sourceHashAfter, run.completionReport) ||
314
+ run[rawCompletionReportHash] === actual.sourceHashAfter) &&
315
+ actual.transitionId === expected.transitionId
316
+ );
317
+ };
318
+
198
319
  /**
199
320
  * Verify every recorded run at its own stamped lock: recompute the plan
200
321
  * content hash and lock hash from the recorded plan and report, then compare
@@ -242,10 +363,121 @@ export const verifyRegradeHistoryRuns = (
242
363
  })
243
364
  );
244
365
  }
366
+ const expectedProvenance = governedProvenanceForRun({
367
+ artifact: run.plan,
368
+ completionReport: run.completionReport,
369
+ planContentHash: run.planContentHash,
370
+ report: run.report,
371
+ });
372
+ if (expectedProvenance.isErr()) {
373
+ return expectedProvenance;
374
+ }
375
+ const transition =
376
+ run.plan.plan.kind === 'vocabulary'
377
+ ? governedTransitionForPlan(run.plan.plan)
378
+ : undefined;
379
+ if (
380
+ transition?.provenance.mode === 'regrade-history' &&
381
+ run.provenance === undefined
382
+ ) {
383
+ return Result.err(
384
+ new ValidationError('Regrade history run lacks governed provenance.', {
385
+ context: { path: artifact.path, run: index },
386
+ })
387
+ );
388
+ }
389
+ if (
390
+ run.provenance !== undefined &&
391
+ !governedProvenanceMatchesRun({
392
+ expected: expectedProvenance.value,
393
+ run,
394
+ })
395
+ ) {
396
+ return Result.err(
397
+ new ValidationError('Regrade history run provenance mismatch.', {
398
+ context: { path: artifact.path, run: index },
399
+ })
400
+ );
401
+ }
245
402
  }
246
403
  return Result.ok({ runs: artifact.runs.length });
247
404
  };
248
405
 
406
+ const historyEntryFor = (params: {
407
+ readonly artifact: RegradePlanArtifact;
408
+ readonly completionReport: RegradeReport;
409
+ readonly lockHashAtRun: string;
410
+ readonly planContentHash: string;
411
+ readonly report: RegradeReport;
412
+ }): TrailsResult<RegradeHistoryRun, ValidationError> => {
413
+ const provenance = governedProvenanceForRun(params);
414
+ if (provenance.isErr()) {
415
+ return provenance;
416
+ }
417
+ return Result.ok({
418
+ completionReport: params.completionReport,
419
+ completionReportHash: regradeSourceHash(params.completionReport),
420
+ lockHashAtRun: params.lockHashAtRun,
421
+ plan: params.artifact,
422
+ planContentHash: params.planContentHash,
423
+ ...(provenance.value === undefined ? {} : { provenance: provenance.value }),
424
+ report: params.report,
425
+ });
426
+ };
427
+
428
+ const historySummaryFor = (
429
+ artifact: Pick<RegradeHistoryArtifact, 'id' | 'path' | 'schemaVersion'>,
430
+ status: RegradeHistorySummary['status'],
431
+ provenance?: GovernedVocabularyHistoryProvenance
432
+ ): RegradeHistorySummary => ({
433
+ id: artifact.id,
434
+ path: artifact.path,
435
+ ...(provenance === undefined ? {} : { provenance }),
436
+ schemaVersion: artifact.schemaVersion,
437
+ status,
438
+ });
439
+
440
+ const writableHistoryArtifact = (artifact: RegradeHistoryArtifact) => ({
441
+ id: artifact.id,
442
+ kind: artifact.kind,
443
+ path: artifact.path,
444
+ runs: artifact.runs.map((run) => ({
445
+ completionReport: run[rawCompletionReport] ?? run.completionReport,
446
+ completionReportHash: run.completionReportHash,
447
+ lockHashAtRun: run.lockHashAtRun,
448
+ plan: run.plan,
449
+ planContentHash: run.planContentHash,
450
+ ...(run.provenance === undefined ? {} : { provenance: run.provenance }),
451
+ report: run[rawReport] ?? run.report,
452
+ })),
453
+ schemaVersion: artifact.schemaVersion,
454
+ });
455
+
456
+ const nextHistoryArtifact = (params: {
457
+ readonly entry: RegradeHistoryRun;
458
+ readonly lockHashAtRun: string;
459
+ readonly path: string;
460
+ readonly plan: RegradePlanArtifact;
461
+ readonly planContentHash: string;
462
+ readonly prior: RegradeHistoryArtifact | undefined;
463
+ }): RegradeHistoryArtifact => ({
464
+ id:
465
+ params.prior?.id ??
466
+ params.plan.transitionId ??
467
+ mintTransitionId(
468
+ regradePlanSlugForBody(params.plan.plan),
469
+ params.planContentHash,
470
+ params.lockHashAtRun
471
+ ),
472
+ kind: 'regrade-history',
473
+ path: params.path,
474
+ runs:
475
+ params.prior === undefined
476
+ ? [params.entry]
477
+ : [...params.prior.runs, params.entry],
478
+ schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
479
+ });
480
+
249
481
  /**
250
482
  * Append one applied run to the transition's consolidated history file. A
251
483
  * run whose plan content hash and source evidence equal either the last run's
@@ -267,14 +499,16 @@ export const appendRegradeHistoryRun = (params: {
267
499
  const lockHashAtRun = regradeSourceHash(params.report);
268
500
  const currentSourceHashes = regradeSourceHashes(params.report);
269
501
  const completionReport = params.completedReport ?? params.report;
270
- const entry: RegradeHistoryRun = {
502
+ const entry = historyEntryFor({
503
+ artifact: params.artifact,
271
504
  completionReport,
272
- completionReportHash: regradeSourceHash(completionReport),
273
505
  lockHashAtRun,
274
- plan: params.artifact,
275
506
  planContentHash,
276
507
  report: params.report,
277
- };
508
+ });
509
+ if (entry.isErr()) {
510
+ return entry;
511
+ }
278
512
 
279
513
  let prior: RegradeHistoryArtifact | undefined;
280
514
  if (existsSync(absolutePath)) {
@@ -334,43 +568,19 @@ export const appendRegradeHistoryRun = (params: {
334
568
  (currentSourceHashes.includes(lastRun.lockHashAtRun) ||
335
569
  currentSourceHashes.includes(lastRun.completionReportHash))
336
570
  ) {
337
- return Result.ok({
338
- path: relativePath,
339
- schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
340
- status: 'replay',
341
- });
571
+ return Result.ok(historySummaryFor(prior, 'replay', lastRun.provenance));
342
572
  }
343
573
  }
344
574
 
345
- const artifact: RegradeHistoryArtifact = {
346
- id:
347
- prior === undefined
348
- ? (params.artifact.transitionId ??
349
- mintTransitionId(
350
- regradePlanSlugForBody(params.artifact.plan),
351
- planContentHash,
352
- lockHashAtRun
353
- ))
354
- : prior.id,
355
- kind: 'regrade-history',
575
+ const artifact = nextHistoryArtifact({
576
+ entry: entry.value,
577
+ lockHashAtRun,
356
578
  path: relativePath,
357
- runs: prior === undefined ? [entry] : [...prior.runs, entry],
358
- schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
359
- };
360
- const writableArtifact = {
361
- id: artifact.id,
362
- kind: artifact.kind,
363
- path: artifact.path,
364
- runs: artifact.runs.map((run) => ({
365
- completionReport: run[rawCompletionReport] ?? run.completionReport,
366
- completionReportHash: run.completionReportHash,
367
- lockHashAtRun: run.lockHashAtRun,
368
- plan: run.plan,
369
- planContentHash: run.planContentHash,
370
- report: run[rawReport] ?? run.report,
371
- })),
372
- schemaVersion: artifact.schemaVersion,
373
- };
579
+ plan: params.artifact,
580
+ planContentHash,
581
+ prior,
582
+ });
583
+ const writableArtifact = writableHistoryArtifact(artifact);
374
584
  const parsed = regradeHistoryArtifactSchema.safeParse(writableArtifact);
375
585
  if (!parsed.success) {
376
586
  return Result.err(
@@ -393,11 +603,9 @@ export const appendRegradeHistoryRun = (params: {
393
603
  })
394
604
  );
395
605
  }
396
- return Result.ok({
397
- path: relativePath,
398
- schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
399
- status: 'applied',
400
- });
606
+ return Result.ok(
607
+ historySummaryFor(artifact, 'applied', entry.value.provenance)
608
+ );
401
609
  };
402
610
 
403
611
  const hasPathSeparator = (value: string): boolean =>
@@ -1,8 +1,123 @@
1
+ import { Result, ValidationError, escapeRegExp } from '@ontrails/core';
2
+ import type { Result as TrailsResult } from '@ontrails/core';
1
3
  import type {
2
4
  VocabularyPreserveInventoryEntry,
3
5
  VocabularyRegradePlan,
4
6
  } from '@ontrails/regrade';
7
+ import { deriveVocabularyFormProposals } from '@ontrails/regrade';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { z } from 'zod';
11
+
12
+ const lockShapeSchema = z
13
+ .object({
14
+ topoGraph: z
15
+ .object({
16
+ entries: z.array(z.object({ id: z.string() }).passthrough()),
17
+ library: z
18
+ .object({
19
+ exports: z.array(
20
+ z.object({ exportName: z.string() }).passthrough()
21
+ ),
22
+ })
23
+ .passthrough()
24
+ .optional(),
25
+ })
26
+ .passthrough(),
27
+ })
28
+ .passthrough();
29
+
30
+ type LockShape = z.output<typeof lockShapeSchema>;
31
+
32
+ const liveApiValues = (
33
+ lock: LockShape
34
+ ): readonly {
35
+ readonly evidence: string;
36
+ readonly value: string;
37
+ }[] => {
38
+ const values = new Map<string, string>();
39
+ for (const entry of lock.topoGraph?.entries ?? []) {
40
+ if (typeof entry.id === 'string') {
41
+ values.set(entry.id, `topo.entry:${entry.id}`);
42
+ }
43
+ }
44
+ for (const item of lock.topoGraph?.library?.exports ?? []) {
45
+ if (typeof item.exportName === 'string') {
46
+ values.set(item.exportName, `topo.library:${item.exportName}`);
47
+ }
48
+ }
49
+ return [...values.entries()]
50
+ .map(([value, evidence]) => ({ evidence, value }))
51
+ .toSorted((left, right) => left.value.localeCompare(right.value));
52
+ };
53
+
54
+ const identifierSegments = (value: string): readonly string[] =>
55
+ value
56
+ .split(/[^A-Za-z0-9]+/u)
57
+ .flatMap(
58
+ (part) => part.match(/[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+/gu) ?? []
59
+ );
60
+
61
+ const liveApiValueContainsSource = (value: string, source: string): boolean => {
62
+ if (/^[A-Za-z][A-Za-z0-9]*$/u.test(source)) {
63
+ const normalized = source.toLowerCase();
64
+ return identifierSegments(value).some(
65
+ (segment) => segment.toLowerCase() === normalized
66
+ );
67
+ }
68
+ return new RegExp(
69
+ `(?<![A-Za-z0-9_$])${escapeRegExp(source)}(?![A-Za-z0-9_$])`,
70
+ 'iu'
71
+ ).test(value);
72
+ };
5
73
 
6
74
  export const deriveLiveApiPreserveInventory = async (
7
- _plan: VocabularyRegradePlan
8
- ): Promise<readonly VocabularyPreserveInventoryEntry[]> => [];
75
+ plan: VocabularyRegradePlan,
76
+ rootDir = process.cwd()
77
+ ): Promise<
78
+ TrailsResult<readonly VocabularyPreserveInventoryEntry[], ValidationError>
79
+ > => {
80
+ const lockPath = join(rootDir, 'trails.lock');
81
+ if (!existsSync(lockPath)) {
82
+ return Result.ok([]);
83
+ }
84
+ let input: unknown;
85
+ try {
86
+ input = JSON.parse(readFileSync(lockPath, 'utf8'));
87
+ } catch (error) {
88
+ return Result.err(
89
+ new ValidationError(
90
+ 'Unable to parse trails.lock for live API preserves.',
91
+ {
92
+ ...(error instanceof Error ? { cause: error } : {}),
93
+ context: { path: lockPath },
94
+ }
95
+ )
96
+ );
97
+ }
98
+ const parsed = lockShapeSchema.safeParse(input);
99
+ if (!parsed.success) {
100
+ return Result.err(
101
+ new ValidationError(
102
+ 'trails.lock does not contain a compatible TopoGraph for live API preserves.',
103
+ { context: { issues: parsed.error.issues, path: lockPath } }
104
+ )
105
+ );
106
+ }
107
+ const sourceForms = deriveVocabularyFormProposals(plan).map(
108
+ (proposal) => proposal.from
109
+ );
110
+ return Result.ok(
111
+ liveApiValues(parsed.data)
112
+ .filter(({ value }) =>
113
+ sourceForms.some((source) => liveApiValueContainsSource(value, source))
114
+ )
115
+ .map(({ evidence, value }) => ({
116
+ disposition: 'preserve-current-live-api' as const,
117
+ evidence: [evidence],
118
+ pattern: escapeRegExp(value),
119
+ reason: 'current-live-topo-api',
120
+ source: 'derived-live-api' as const,
121
+ }))
122
+ );
123
+ };