@ontrails/regrade 1.0.0-beta.32 → 1.0.0-beta.39

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.
@@ -8,6 +8,7 @@ import type { ScanTargets } from '@ontrails/core';
8
8
  import type {
9
9
  WardenDiagnostic,
10
10
  WardenFixEdit,
11
+ WardenGuidance,
11
12
  WardenRule,
12
13
  } from '@ontrails/warden';
13
14
  import {
@@ -143,18 +144,39 @@ export interface RegradeReviewSpan {
143
144
  readonly start: number;
144
145
  }
145
146
 
147
+ /**
148
+ * Verdict state for a review detail.
149
+ *
150
+ * - `unresolved`: the class could not complete occurrence judgment; a human or
151
+ * agent decision is still needed.
152
+ * - `preserve`: a completed verdict to keep the occurrence as-is.
153
+ * - `rewrite`: a completed verdict that a rewrite is intended but this run
154
+ * could not apply it (for example invalid or missing edits).
155
+ */
156
+ export type RegradeReviewJudgment = 'preserve' | 'rewrite' | 'unresolved';
157
+
146
158
  /** Structured detail explaining why a source match needs review. */
147
159
  export interface RegradeReviewDetail {
160
+ /** Concrete replacement the class would apply if the occurrence were judged safe. */
161
+ readonly candidateReplacement?: string;
148
162
  /** Class that produced the review detail, injected by report building. */
149
163
  readonly classId?: string;
150
164
  /** Expected target shape when the class can describe one. */
151
165
  readonly expectedTarget?: string;
152
166
  /** Fixture or example reference that illustrates the expected migration. */
153
167
  readonly fixture?: string;
168
+ /** Whether occurrence judgment is unresolved or a preserve/rewrite verdict completed. */
169
+ readonly judgment?: RegradeReviewJudgment;
170
+ /** Exact matched source text for the occurrence under review. */
171
+ readonly matchedForm?: string;
154
172
  /** AST node kind or source construct kind. */
155
173
  readonly nodeKind?: string;
174
+ /** Cautions explaining why a blind rewrite of this occurrence is unsafe. */
175
+ readonly preserveCautions?: readonly string[];
156
176
  /** Machine-readable reason for review. */
157
177
  readonly reason: string;
178
+ /** Machine-readable provenance tags for the producing rule or class. */
179
+ readonly signals?: readonly string[];
158
180
  /** Source span and line/column for the review-required match. */
159
181
  readonly span?: RegradeReviewSpan;
160
182
  /** Suggested validation command after the review is resolved. */
@@ -317,7 +339,9 @@ const diagnosticSpan = (
317
339
  return spanForSymbolOnDiagnosticLine(source, diagnostic.line, symbol);
318
340
  };
319
341
 
320
- const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
342
+ const diagnosticCandidateReplacement = (
343
+ diagnostic: WardenDiagnostic
344
+ ): string | undefined => {
321
345
  const replacements = new Set(
322
346
  (diagnostic.fix?.edits ?? []).map((edit) => edit.replacement)
323
347
  );
@@ -325,32 +349,98 @@ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
325
349
  return undefined;
326
350
  }
327
351
  const [replacement] = replacements;
352
+ return replacement;
353
+ };
354
+
355
+ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
356
+ const replacement = diagnosticCandidateReplacement(diagnostic);
328
357
  return replacement === undefined
329
358
  ? undefined
330
359
  : `Replace with "${replacement}".`;
331
360
  };
332
361
 
362
+ const isValidEditSpan = (
363
+ source: string,
364
+ edit: WardenFixEdit | undefined
365
+ ): edit is WardenFixEdit =>
366
+ edit !== undefined &&
367
+ Number.isInteger(edit.start) &&
368
+ Number.isInteger(edit.end) &&
369
+ edit.start >= 0 &&
370
+ edit.end >= edit.start &&
371
+ edit.end <= source.length;
372
+
373
+ const diagnosticMatchedForm = (
374
+ source: string,
375
+ diagnostic: WardenDiagnostic,
376
+ symbol: string | undefined
377
+ ): string | undefined => {
378
+ const [edit] = diagnostic.fix?.edits ?? [];
379
+ if (isValidEditSpan(source, edit)) {
380
+ return source.slice(edit.start, edit.end);
381
+ }
382
+ return symbol;
383
+ };
384
+
385
+ const diagnosticSignals = (diagnostic: WardenDiagnostic): readonly string[] => [
386
+ `warden:${diagnostic.rule}`,
387
+ ...(diagnostic.code === undefined
388
+ ? []
389
+ : [`${diagnostic.rule}:${diagnostic.code}`]),
390
+ ];
391
+
392
+ interface WardenReviewMappingOptions {
393
+ /** Verdict state for this review path. */
394
+ readonly judgment: RegradeReviewJudgment;
395
+ /** Machine-readable review reason. */
396
+ readonly reason: string;
397
+ /** Rule-level guidance used when a finding carries none of its own. */
398
+ readonly ruleGuidance?: WardenGuidance;
399
+ }
400
+
401
+ const reviewDetailFromDiagnostic = (
402
+ source: string,
403
+ diagnostic: WardenDiagnostic,
404
+ options: WardenReviewMappingOptions
405
+ ): RegradeReviewDetail => {
406
+ const symbol =
407
+ firstQuotedValue(diagnostic.fix?.reason) ??
408
+ firstQuotedValue(diagnostic.message);
409
+ const span = diagnosticSpan(source, diagnostic, symbol);
410
+ const target = expectedTarget(diagnostic);
411
+ const replacement = diagnosticCandidateReplacement(diagnostic);
412
+ const matchedForm = diagnosticMatchedForm(source, diagnostic, symbol);
413
+ const guidance = diagnostic.guidance ?? options.ruleGuidance;
414
+ const preserveCautions =
415
+ guidance === undefined
416
+ ? undefined
417
+ : [guidance.summary, ...(guidance.steps ?? [])];
418
+ const suggestedValidation = guidance?.commands?.[0];
419
+ return {
420
+ ...(replacement === undefined ? {} : { candidateReplacement: replacement }),
421
+ ...(target === undefined ? {} : { expectedTarget: target }),
422
+ ...(diagnostic.fix?.fixture === undefined
423
+ ? {}
424
+ : { fixture: diagnostic.fix.fixture }),
425
+ judgment: options.judgment,
426
+ ...(matchedForm === undefined ? {} : { matchedForm }),
427
+ ...(preserveCautions === undefined ? {} : { preserveCautions }),
428
+ reason: options.reason,
429
+ signals: diagnosticSignals(diagnostic),
430
+ ...(span === undefined ? {} : { span }),
431
+ ...(suggestedValidation === undefined ? {} : { suggestedValidation }),
432
+ ...(symbol === undefined ? {} : { symbol }),
433
+ } satisfies RegradeReviewDetail;
434
+ };
435
+
333
436
  const reviewDetailsFromDiagnostics = (
334
437
  source: string,
335
438
  diagnostics: readonly WardenDiagnostic[],
336
- reason: string
439
+ options: WardenReviewMappingOptions
337
440
  ): readonly RegradeReviewDetail[] | undefined => {
338
- const details = diagnostics.map((diagnostic) => {
339
- const symbol =
340
- firstQuotedValue(diagnostic.fix?.reason) ??
341
- firstQuotedValue(diagnostic.message);
342
- const span = diagnosticSpan(source, diagnostic, symbol);
343
- const target = expectedTarget(diagnostic);
344
- return {
345
- ...(target === undefined ? {} : { expectedTarget: target }),
346
- ...(diagnostic.fix?.fixture === undefined
347
- ? {}
348
- : { fixture: diagnostic.fix.fixture }),
349
- reason,
350
- ...(span === undefined ? {} : { span }),
351
- ...(symbol === undefined ? {} : { symbol }),
352
- } satisfies RegradeReviewDetail;
353
- });
441
+ const details = diagnostics.map((diagnostic) =>
442
+ reviewDetailFromDiagnostic(source, diagnostic, options)
443
+ );
354
444
  return details.length === 0 ? undefined : details;
355
445
  };
356
446
 
@@ -431,10 +521,18 @@ export const createWardenTermRewriteClass = (
431
521
  (diagnostic) => diagnostic.fix?.safety !== 'safe'
432
522
  );
433
523
  if (reviewDiagnostics.length > 0) {
524
+ // The rule flagged the occurrence but marked it review: occurrence
525
+ // judgment is unresolved and needs a human or agent decision.
434
526
  const reviewDetails = reviewDetailsFromDiagnostics(
435
527
  source,
436
528
  reviewDiagnostics,
437
- 'warden-review-required'
529
+ {
530
+ judgment: 'unresolved',
531
+ reason: 'warden-review-required',
532
+ ...(metadata.guidance === undefined
533
+ ? {}
534
+ : { ruleGuidance: metadata.guidance }),
535
+ }
438
536
  );
439
537
  return {
440
538
  kind: 'needs-review',
@@ -448,10 +546,18 @@ export const createWardenTermRewriteClass = (
448
546
  (diagnostic) => (diagnostic.fix?.edits?.length ?? 0) === 0
449
547
  );
450
548
  if (diagnosticsMissingEdits.length > 0) {
549
+ // A safe fix without concrete edits cannot complete occurrence
550
+ // judgment on its own, so the verdict stays unresolved.
451
551
  const reviewDetails = reviewDetailsFromDiagnostics(
452
552
  source,
453
553
  diagnosticsMissingEdits,
454
- 'warden-fix-missing-edits'
554
+ {
555
+ judgment: 'unresolved',
556
+ reason: 'warden-fix-missing-edits',
557
+ ...(metadata.guidance === undefined
558
+ ? {}
559
+ : { ruleGuidance: metadata.guidance }),
560
+ }
455
561
  );
456
562
  return {
457
563
  kind: 'needs-review',
@@ -466,10 +572,18 @@ export const createWardenTermRewriteClass = (
466
572
  );
467
573
  const application = applyWardenEdits(source, edits);
468
574
  if (!application.ok) {
575
+ // The rule completed judgment — it authored concrete edits — but this
576
+ // run could not apply them, so the verdict is a rewrite left undone.
469
577
  const reviewDetails = reviewDetailsFromDiagnostics(
470
578
  source,
471
579
  diagnostics,
472
- 'warden-fix-invalid'
580
+ {
581
+ judgment: 'rewrite',
582
+ reason: 'warden-fix-invalid',
583
+ ...(metadata.guidance === undefined
584
+ ? {}
585
+ : { ruleGuidance: metadata.guidance }),
586
+ }
473
587
  );
474
588
  return {
475
589
  kind: 'needs-review',
@@ -567,6 +681,9 @@ const deriveCollectionOptions = (
567
681
  ...(collection?.exclude === undefined
568
682
  ? {}
569
683
  : { exclude: collection.exclude }),
684
+ ...(collection?.include === undefined
685
+ ? {}
686
+ : { include: collection.include }),
570
687
  extensions: collection?.extensions ?? targetExtensions,
571
688
  ignoredDirectories:
572
689
  collection?.ignoredDirectories ??
@@ -619,6 +736,28 @@ export interface RegradeReport {
619
736
  readonly apply?: RegradeApplySummary;
620
737
  /** Vocabulary regrade run: plan, ledger, and completion report. */
621
738
  readonly run?: VocabularyRegradeRun;
739
+ /** Saved active Regrade plan evidence for vocabulary regrades. */
740
+ readonly plan?: {
741
+ readonly expansionPending?: number;
742
+ readonly path: string;
743
+ readonly schemaVersion: number;
744
+ readonly status: 'active' | 'stale';
745
+ };
746
+ /** Saved applied Regrade history evidence for vocabulary regrades. */
747
+ readonly history?: {
748
+ readonly path: string;
749
+ readonly schemaVersion: number;
750
+ readonly status: 'applied' | 'checked' | 'replay';
751
+ };
752
+ /**
753
+ * @deprecated Persisted transition record evidence for vocabulary regrades.
754
+ * Use `plan` and `history` summaries in public surfaces.
755
+ */
756
+ readonly record?: {
757
+ readonly path: string;
758
+ readonly schemaVersion: number;
759
+ readonly status: 'candidate' | 'applied' | 'checked';
760
+ };
622
761
  }
623
762
 
624
763
  interface RegradeRewriteCandidate {
@@ -690,16 +829,20 @@ const classifyFile = (
690
829
  selected: readonly RegradeClass[],
691
830
  collection?: DownstreamCollectionOptions
692
831
  ): RegradeClassifiedFile => {
693
- // First selected class that matches (rewrite or review) wins, mirroring the
694
- // "run one class" emphasis. Scan-target skips only own the file when no
695
- // selected class inspects it; a later no-op still counts as a clean scan.
832
+ // Compose safe rewrites across selected classes in memory so one governed
833
+ // transition can move every compatible symbol in a file. Review still wins:
834
+ // if any class needs judgment, no partial rewrite is returned for that file.
696
835
  let skipped:
697
836
  | { readonly classId: string; readonly result: RegradeClassResult }
698
837
  | undefined;
699
838
  let inspected = false;
839
+ let currentSource = source;
840
+ const rewriteClassIds: string[] = [];
841
+ const rewriteNotes: string[] = [];
700
842
  for (const cls of selected) {
701
843
  const result =
702
- classScanTargetSkip(cls, path, collection) ?? cls.apply(source, context);
844
+ classScanTargetSkip(cls, path, collection) ??
845
+ cls.apply(currentSource, context);
703
846
  if (result.kind !== 'skipped') {
704
847
  inspected = true;
705
848
  }
@@ -715,38 +858,29 @@ const classifyFile = (
715
858
  },
716
859
  };
717
860
  }
718
- const entry = {
719
- classId: cls.id,
720
- notes: result.notes,
721
- outcome: 'rewrite',
722
- path,
723
- } satisfies RegradeReportEntry;
724
- return {
725
- entry,
726
- ...(context.absolutePath === undefined
727
- ? {}
728
- : {
729
- rewrite: {
730
- absolutePath: context.absolutePath,
731
- classId: cls.id,
732
- nextSource: result.nextSource,
733
- path,
734
- },
735
- }),
736
- };
861
+ currentSource = result.nextSource;
862
+ rewriteClassIds.push(cls.id);
863
+ rewriteNotes.push(...(result.notes ?? []));
864
+ continue;
737
865
  }
738
866
  if (result.kind === 'needs-review') {
739
- const reviewDetails = result.reviewDetails?.map((detail) => ({
867
+ const originalSourceResult =
868
+ currentSource === source ? result : cls.apply(source, context);
869
+ const reviewResult =
870
+ originalSourceResult.kind === 'needs-review'
871
+ ? originalSourceResult
872
+ : result;
873
+ const reviewDetails = reviewResult.reviewDetails?.map((detail) => ({
740
874
  ...detail,
741
875
  classId: detail.classId ?? cls.id,
742
876
  }));
743
877
  return {
744
878
  entry: {
745
879
  classId: cls.id,
746
- notes: result.notes,
880
+ notes: reviewResult.notes,
747
881
  outcome: 'needs-review',
748
882
  path,
749
- reason: result.reason ?? 'needs-review',
883
+ reason: reviewResult.reason ?? 'needs-review',
750
884
  ...(reviewDetails === undefined ? {} : { reviewDetails }),
751
885
  },
752
886
  };
@@ -755,6 +889,28 @@ const classifyFile = (
755
889
  skipped = { classId: cls.id, result };
756
890
  }
757
891
  }
892
+ if (rewriteClassIds.length > 0) {
893
+ const classId = rewriteClassIds.join(',');
894
+ const entry = {
895
+ classId,
896
+ notes: rewriteNotes,
897
+ outcome: 'rewrite',
898
+ path,
899
+ } satisfies RegradeReportEntry;
900
+ return {
901
+ entry,
902
+ ...(context.absolutePath === undefined
903
+ ? {}
904
+ : {
905
+ rewrite: {
906
+ absolutePath: context.absolutePath,
907
+ classId,
908
+ nextSource: currentSource,
909
+ path,
910
+ },
911
+ }),
912
+ };
913
+ }
758
914
  if (!inspected && skipped !== undefined) {
759
915
  return {
760
916
  entry: {
@@ -1083,6 +1239,12 @@ const regradeReportEntrySchema = z.object({
1083
1239
  reviewDetails: z
1084
1240
  .array(
1085
1241
  z.object({
1242
+ candidateReplacement: z
1243
+ .string()
1244
+ .optional()
1245
+ .describe(
1246
+ 'Concrete replacement the class would apply if the occurrence were judged safe'
1247
+ ),
1086
1248
  classId: z
1087
1249
  .string()
1088
1250
  .optional()
@@ -1095,11 +1257,35 @@ const regradeReportEntrySchema = z.object({
1095
1257
  .string()
1096
1258
  .optional()
1097
1259
  .describe('Fixture or example reference for the migration'),
1260
+ judgment: z
1261
+ .enum(['preserve', 'rewrite', 'unresolved'])
1262
+ .optional()
1263
+ .describe(
1264
+ 'Verdict state: unresolved = occurrence judgment is incomplete and needs a human or agent decision; preserve = completed verdict to keep the occurrence; rewrite = completed verdict that a rewrite is intended but this run could not apply it'
1265
+ ),
1266
+ matchedForm: z
1267
+ .string()
1268
+ .optional()
1269
+ .describe(
1270
+ 'Exact matched source text for the occurrence under review'
1271
+ ),
1098
1272
  nodeKind: z
1099
1273
  .string()
1100
1274
  .optional()
1101
1275
  .describe('AST node kind or source construct kind'),
1276
+ preserveCautions: z
1277
+ .array(z.string())
1278
+ .optional()
1279
+ .describe(
1280
+ 'Cautions explaining why a blind rewrite of this occurrence is unsafe'
1281
+ ),
1102
1282
  reason: z.string().describe('Machine-readable review reason'),
1283
+ signals: z
1284
+ .array(z.string())
1285
+ .optional()
1286
+ .describe(
1287
+ 'Machine-readable provenance tags for the producing rule or class'
1288
+ ),
1103
1289
  span: z
1104
1290
  .object({
1105
1291
  column: z.number().describe('One-based source column'),
@@ -1137,7 +1323,43 @@ export const regradeReportOutput = z.object({
1137
1323
  .describe(
1138
1324
  'Per-entry detail, sorted by path. Defaults to actionable rewrite/review entries.'
1139
1325
  ),
1326
+ history: z
1327
+ .object({
1328
+ path: z.string().describe('Root-relative applied history entry path'),
1329
+ schemaVersion: z.number().describe('Regrade history schema version'),
1330
+ status: z
1331
+ .enum(['applied', 'checked', 'replay'])
1332
+ .describe(
1333
+ 'How this command used the Regrade history file: applied = run appended, replay = identical re-run recognized and not duplicated, checked = consolidated history verified per-run'
1334
+ ),
1335
+ })
1336
+ .optional()
1337
+ .describe('Saved applied Regrade history evidence'),
1140
1338
  matched: z.number().describe('Files with a rewrite or review outcome'),
1339
+ plan: z
1340
+ .object({
1341
+ expansionPending: z
1342
+ .number()
1343
+ .optional()
1344
+ .describe('Pending staged expansion candidates on this plan'),
1345
+ path: z.string().describe('Root-relative Regrade plan path'),
1346
+ schemaVersion: z.number().describe('Regrade plan schema version'),
1347
+ status: z
1348
+ .enum(['active', 'stale'])
1349
+ .describe('Whether the saved plan still matches the source tree'),
1350
+ })
1351
+ .optional()
1352
+ .describe('Saved active Regrade plan evidence'),
1353
+ record: z
1354
+ .object({
1355
+ path: z.string().describe('Root-relative transition record path'),
1356
+ schemaVersion: z.number().describe('Transition record schema version'),
1357
+ status: z
1358
+ .enum(['candidate', 'applied', 'checked'])
1359
+ .describe('How this command used the transition record'),
1360
+ })
1361
+ .optional()
1362
+ .describe('Persisted transition record evidence'),
1141
1363
  review: z.number().describe('Files routed to review'),
1142
1364
  rewritten: z.number().describe('Files with a rewrite outcome'),
1143
1365
  root: z.string().describe('Root the run scanned'),
@@ -0,0 +1,98 @@
1
+ import type { GovernedVocabularyTransition } from '@ontrails/warden';
2
+ import { listGovernedVocabularyTransitions } from '@ontrails/warden';
3
+
4
+ import type {
5
+ VocabularyPreserveRule,
6
+ VocabularyRegradePlan,
7
+ } from './vocabulary.js';
8
+
9
+ const pluralizeVocabularyForm = (value: string): string =>
10
+ value.endsWith('s') || value.endsWith('x') || value.endsWith('ch')
11
+ ? `${value}es`
12
+ : `${value}s`;
13
+
14
+ const preserveRulesFromTransition = (
15
+ transition: GovernedVocabularyTransition
16
+ ): readonly VocabularyPreserveRule[] =>
17
+ transition.preserve.map((rule) => ({
18
+ ...(rule.paths === undefined ? {} : { paths: rule.paths }),
19
+ pattern: rule.pattern,
20
+ reason: rule.reason,
21
+ }));
22
+
23
+ const scopeFromTransition = (
24
+ transition: GovernedVocabularyTransition
25
+ ): VocabularyRegradePlan['scope'] | undefined => {
26
+ const { scope } = transition;
27
+ if (scope === undefined) {
28
+ return undefined;
29
+ }
30
+ return {
31
+ ...(scope.exclude === undefined ? {} : { exclude: scope.exclude }),
32
+ ...(scope.extensions === undefined ? {} : { extensions: scope.extensions }),
33
+ ...(scope.ignoredDirectories === undefined
34
+ ? {}
35
+ : { ignoredDirectories: scope.ignoredDirectories }),
36
+ ...(scope.include === undefined ? {} : { include: scope.include }),
37
+ };
38
+ };
39
+
40
+ const defaultFormsAreRegistrySafe = (
41
+ transition: GovernedVocabularyTransition
42
+ ): boolean => {
43
+ if (transition.target.kind !== 'single') {
44
+ return false;
45
+ }
46
+
47
+ const defaultForms = [
48
+ transition.from,
49
+ pluralizeVocabularyForm(transition.from),
50
+ ];
51
+
52
+ return defaultForms.every((form) => {
53
+ const replacement = transition.safeRewriteForms[form];
54
+ return replacement !== undefined && !transition.reviewForms.includes(form);
55
+ });
56
+ };
57
+
58
+ export const vocabularyRegradePlanFromTransition = (
59
+ transition: GovernedVocabularyTransition
60
+ ): VocabularyRegradePlan | null => {
61
+ if (
62
+ transition.target.kind !== 'single' ||
63
+ !defaultFormsAreRegistrySafe(transition)
64
+ ) {
65
+ return null;
66
+ }
67
+
68
+ const scope = scopeFromTransition(transition);
69
+ return {
70
+ caseSensitive: true,
71
+ deferForms: transition.reviewForms,
72
+ from: transition.from,
73
+ id: transition.id,
74
+ intent: transition.intent,
75
+ kind: 'vocabulary',
76
+ overrides: transition.safeRewriteForms,
77
+ preserve: preserveRulesFromTransition(transition),
78
+ ...(scope === undefined ? {} : { scope }),
79
+ to: transition.target.to,
80
+ };
81
+ };
82
+
83
+ export const listVocabularyRegradePlansFromRegistry =
84
+ (): readonly VocabularyRegradePlan[] =>
85
+ listGovernedVocabularyTransitions()
86
+ .map(vocabularyRegradePlanFromTransition)
87
+ .filter((plan): plan is VocabularyRegradePlan => plan !== null);
88
+
89
+ export const vocabularyRegradeTransitionForInput = (
90
+ from: string,
91
+ to: string
92
+ ): GovernedVocabularyTransition | undefined =>
93
+ listGovernedVocabularyTransitions().find(
94
+ (transition) =>
95
+ transition.from === from &&
96
+ transition.target.kind === 'single' &&
97
+ transition.target.to === to
98
+ );