@ontrails/regrade 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.
@@ -3,6 +3,7 @@ import {
3
3
  Result,
4
4
  ValidationError,
5
5
  escapeRegExp,
6
+ isPlainObject,
6
7
  matchesAnyPathGlob,
7
8
  } from '@ontrails/core';
8
9
  import { createHash } from 'node:crypto';
@@ -17,7 +18,10 @@ import {
17
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
19
  import { z } from 'zod';
19
20
 
20
- import { collectDownstreamSources } from './collect.js';
21
+ import {
22
+ DEFAULT_IGNORED_DIRECTORIES,
23
+ collectDownstreamSources,
24
+ } from './collect.js';
21
25
  import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
22
26
  import type {
23
27
  RegradeApplySummary,
@@ -26,13 +30,14 @@ import type {
26
30
  } from './report.js';
27
31
  import { buildRegradeScanSummary } from './scan-summary.js';
28
32
 
29
- export type VocabularyVerdict = 'deferred' | 'modified' | 'skipped';
33
+ export type VocabularyVerdict = 'applied' | 'deferred' | 'modified' | 'skipped';
30
34
 
31
35
  export const vocabularyDispositionValues = [
32
36
  'code-context-out-of-engine',
33
37
  'docs-only',
34
38
  'explicit-preserve',
35
39
  'forward-pointer',
40
+ 'historical-by-policy',
36
41
  'ignored-by-scope',
37
42
  'in-family-modified',
38
43
  'in-family-unresolved',
@@ -58,6 +63,15 @@ export interface VocabularyPreserveInventoryEntry extends VocabularyPreserveRule
58
63
  readonly source: 'derived-live-api';
59
64
  }
60
65
 
66
+ export interface VocabularyScopePolicy {
67
+ readonly disposition: VocabularyDisposition;
68
+ readonly expectMatches?: boolean | undefined;
69
+ readonly paths: readonly string[];
70
+ readonly reason: string;
71
+ }
72
+
73
+ export type VocabularyScopeTier = 'in-scope' | 'policy-classified';
74
+
61
75
  export interface VocabularyRegradeScope {
62
76
  readonly exclude?: readonly string[];
63
77
  readonly extensions?: readonly string[];
@@ -68,11 +82,72 @@ export interface VocabularyRegradeScope {
68
82
  */
69
83
  readonly ignoredDirectories?: readonly string[];
70
84
  readonly include?: readonly string[];
85
+ readonly policyClassified?: readonly VocabularyScopePolicy[];
86
+ readonly teachingSurfaces?: readonly string[];
87
+ }
88
+
89
+ /**
90
+ * One authored root-relative file move in a vocabulary plan.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const rename: VocabularyFileRename = {
95
+ * from: 'docs/old.md',
96
+ * to: 'docs/new.md',
97
+ * };
98
+ * ```
99
+ */
100
+ export interface VocabularyFileRename {
101
+ readonly from: string;
102
+ readonly to: string;
103
+ }
104
+
105
+ /**
106
+ * Derived reference-closure totals for one governed file move.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * console.log(evidence.rewritten, evidence.historical);
111
+ * ```
112
+ */
113
+ export interface VocabularyFileRenameEvidence extends VocabularyFileRename {
114
+ readonly deferred: number;
115
+ readonly historical: number;
116
+ readonly preserved: number;
117
+ readonly rewritten: number;
118
+ readonly skipped: number;
119
+ }
120
+
121
+ /**
122
+ * One deterministic form proposal synthesized from a vocabulary seed.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const proposal: VocabularyFormProposal = {
127
+ * from: 'legacies',
128
+ * kind: 'safe-rewrite',
129
+ * reason: 'default-morphology',
130
+ * source: 'default-morphology',
131
+ * to: 'currents',
132
+ * };
133
+ * ```
134
+ */
135
+ export interface VocabularyFormProposal {
136
+ readonly from: string;
137
+ readonly kind: 'review' | 'safe-rewrite';
138
+ readonly reason: string;
139
+ readonly source:
140
+ | 'default-morphology'
141
+ | 'plan-defer'
142
+ | 'plan-override'
143
+ | 'seed';
144
+ readonly to?: string;
71
145
  }
72
146
 
73
147
  export interface VocabularyRegradePlan {
74
148
  readonly caseSensitive?: boolean;
75
149
  readonly deferForms?: readonly string[];
150
+ readonly fileRenames?: readonly VocabularyFileRename[];
76
151
  readonly from: string;
77
152
  readonly id?: string;
78
153
  readonly intent?: string;
@@ -94,6 +169,7 @@ export interface VocabularyOccurrence {
94
169
  readonly reason: string;
95
170
  readonly replacement?: string;
96
171
  readonly start: number;
172
+ readonly scopeTier: VocabularyScopeTier;
97
173
  readonly verdict: VocabularyVerdict;
98
174
  }
99
175
 
@@ -119,10 +195,17 @@ export interface VocabularyRunReport {
119
195
  Readonly<Record<VocabularyDisposition, number>>
120
196
  >;
121
197
  readonly filesChanged: number;
198
+ readonly fileRenames?: readonly VocabularyFileRenameEvidence[];
122
199
  readonly gate: VocabularyRunGate;
123
200
  readonly modified: number;
124
201
  readonly open: number;
125
202
  readonly skipped: number;
203
+ readonly scopeTiers: Readonly<Record<VocabularyScopeTier, number>>;
204
+ readonly teachingSurfaces: {
205
+ readonly expected: readonly string[];
206
+ readonly missing: readonly string[];
207
+ readonly touched: readonly string[];
208
+ };
126
209
  }
127
210
 
128
211
  export interface VocabularyRegradeRun {
@@ -172,7 +255,7 @@ interface SourceOccurrence extends VocabularyOccurrence {
172
255
 
173
256
  interface SourceOccurrenceDraft extends Omit<
174
257
  SourceOccurrence,
175
- 'disposition' | 'reason' | 'verdict'
258
+ 'disposition' | 'reason' | 'scopeTier' | 'verdict'
176
259
  > {
177
260
  readonly contextColumn: number;
178
261
  }
@@ -220,6 +303,81 @@ const vocabularyDispositionCounts = (
220
303
  );
221
304
  };
222
305
 
306
+ const vocabularyFormVerdicts = (
307
+ occurrences: readonly VocabularyOccurrence[]
308
+ ): Readonly<Record<string, VocabularyVerdict>> => {
309
+ const priority: Readonly<Record<VocabularyVerdict, number>> = {
310
+ applied: 1,
311
+ deferred: 3,
312
+ modified: 2,
313
+ skipped: 0,
314
+ };
315
+ const forms: Record<string, VocabularyVerdict> = {};
316
+ for (const occurrence of occurrences) {
317
+ if (occurrence.verdict === 'applied') {
318
+ continue;
319
+ }
320
+ const current = forms[occurrence.form];
321
+ if (
322
+ current === undefined ||
323
+ priority[occurrence.verdict] > priority[current]
324
+ ) {
325
+ forms[occurrence.form] = occurrence.verdict;
326
+ }
327
+ }
328
+ return forms;
329
+ };
330
+
331
+ const vocabularyScopeTierCounts = (
332
+ occurrences: readonly VocabularyOccurrence[]
333
+ ): Readonly<Record<VocabularyScopeTier, number>> => ({
334
+ 'in-scope': occurrences.filter(
335
+ (occurrence) => occurrence.scopeTier === 'in-scope'
336
+ ).length,
337
+ 'policy-classified': occurrences.filter(
338
+ (occurrence) => occurrence.scopeTier === 'policy-classified'
339
+ ).length,
340
+ });
341
+
342
+ const vocabularyScopeEvidence = (
343
+ plan: VocabularyRegradePlan,
344
+ occurrences: readonly VocabularyOccurrence[]
345
+ ): {
346
+ readonly gateReasons: readonly string[];
347
+ readonly scopeTiers: Readonly<Record<VocabularyScopeTier, number>>;
348
+ readonly teachingSurfaces: VocabularyRunReport['teachingSurfaces'];
349
+ } => {
350
+ const expected = uniqueSorted(plan.scope?.teachingSurfaces ?? []);
351
+ const touched = expected.filter((pattern) =>
352
+ occurrences.some(
353
+ (occurrence) =>
354
+ occurrence.scopeTier === 'in-scope' &&
355
+ matchesAnyPathGlob(occurrence.path, [pattern])
356
+ )
357
+ );
358
+ const missing = expected.filter((pattern) => !touched.includes(pattern));
359
+ const expectedPolicyMissing =
360
+ plan.scope?.policyClassified?.some(
361
+ (policy) =>
362
+ policy.expectMatches === true &&
363
+ !occurrences.some(
364
+ (occurrence) =>
365
+ occurrence.scopeTier === 'policy-classified' &&
366
+ matchesAnyPathGlob(occurrence.path, policy.paths)
367
+ )
368
+ ) ?? false;
369
+ return {
370
+ gateReasons: [
371
+ ...(expectedPolicyMissing
372
+ ? ['expected-policy-classified-evidence-missing']
373
+ : []),
374
+ ...(missing.length === 0 ? [] : ['expected-teaching-surfaces-missing']),
375
+ ],
376
+ scopeTiers: vocabularyScopeTierCounts(occurrences),
377
+ teachingSurfaces: { expected, missing, touched },
378
+ };
379
+ };
380
+
223
381
  const isVocabularyTokenCharacter = (
224
382
  value: string,
225
383
  routeLike: boolean
@@ -559,6 +717,10 @@ const targetFormsForPlan = (
559
717
  return forms;
560
718
  };
561
719
 
720
+ export const vocabularyRewriteFormsForPlan = (
721
+ plan: VocabularyRegradePlan
722
+ ): readonly (readonly [string, string])[] => [...targetFormsForPlan(plan)];
723
+
562
724
  const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
563
725
  const overrideForms = new Set(
564
726
  normalizedOverrideEntries(plan.overrides).map(([form]) =>
@@ -573,6 +735,140 @@ const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
573
735
  ]);
574
736
  };
575
737
 
738
+ /**
739
+ * Synthesize the deterministic form proposal carried by a minimal plan seed.
740
+ *
741
+ * @example
742
+ * ```ts
743
+ * const forms = deriveVocabularyFormProposals({
744
+ * from: 'legacy',
745
+ * kind: 'vocabulary',
746
+ * to: 'current',
747
+ * });
748
+ * ```
749
+ */
750
+ export const deriveVocabularyFormProposals = (
751
+ plan: VocabularyRegradePlan
752
+ ): readonly VocabularyFormProposal[] => {
753
+ const overrideForms = new Set(Object.keys(plan.overrides ?? {}));
754
+ const explicitDefers = new Set(
755
+ (plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
756
+ );
757
+ const safeProposals = [...targetFormsForPlan(plan).entries()].flatMap(
758
+ ([from, to]): readonly VocabularyFormProposal[] => {
759
+ if (explicitDefers.has(formIdentityForPlan(plan, from))) {
760
+ return [];
761
+ }
762
+ if (from === plan.from) {
763
+ return [
764
+ {
765
+ from,
766
+ kind: 'safe-rewrite',
767
+ reason: 'minimal-seed',
768
+ source: 'seed',
769
+ to,
770
+ },
771
+ ];
772
+ }
773
+ if (overrideForms.has(from)) {
774
+ return [
775
+ {
776
+ from,
777
+ kind: 'safe-rewrite',
778
+ reason: 'authored-or-governed-override',
779
+ source: 'plan-override',
780
+ to,
781
+ },
782
+ ];
783
+ }
784
+ return [
785
+ {
786
+ from,
787
+ kind: 'safe-rewrite',
788
+ reason: 'default-morphology',
789
+ source: 'default-morphology',
790
+ to,
791
+ },
792
+ ];
793
+ }
794
+ );
795
+ const casingProposals: VocabularyFormProposal[] = [];
796
+ if (isSimpleVocabularyWord(plan.from) && isSimpleVocabularyWord(plan.to)) {
797
+ const from = `${plan.from.slice(0, 1).toUpperCase()}${plan.from.slice(1)}`;
798
+ const to = `${plan.to.slice(0, 1).toUpperCase()}${plan.to.slice(1)}`;
799
+ if (from !== plan.from && !explicitDefers.has(from)) {
800
+ casingProposals.push({
801
+ from,
802
+ kind: 'review',
803
+ reason: 'uncertain-casing-or-public-name',
804
+ source: 'default-morphology',
805
+ to,
806
+ });
807
+ }
808
+ }
809
+ return [
810
+ ...safeProposals,
811
+ ...casingProposals,
812
+ ...deferFormsForPlan(plan).map((from) => ({
813
+ from,
814
+ kind: 'review' as const,
815
+ reason: explicitDefers.has(from)
816
+ ? 'authored-or-governed-defer'
817
+ : 'uncertain-morphology',
818
+ source: explicitDefers.has(from)
819
+ ? ('plan-defer' as const)
820
+ : ('default-morphology' as const),
821
+ })),
822
+ ].toSorted((left, right) =>
823
+ left.from === right.from
824
+ ? left.kind.localeCompare(right.kind)
825
+ : left.from.localeCompare(right.from)
826
+ );
827
+ };
828
+
829
+ const validateVocabularyScope = (
830
+ scope: VocabularyRegradeScope | undefined
831
+ ): Result<void, ValidationError> => {
832
+ const excludedDocsPattern = scope?.exclude?.find(
833
+ (pattern) =>
834
+ /(^|\/)docs(?:\/|$)/.test(pattern) ||
835
+ matchesAnyPathGlob('docs/regrade-teaching.md', [pattern]) ||
836
+ matchesAnyPathGlob('docs/regrade-teaching.mdx', [pattern])
837
+ );
838
+ if (excludedDocsPattern !== undefined) {
839
+ return Result.err(
840
+ new ValidationError(
841
+ `Vocabulary Regrade plans cannot hard-exclude docs with "${excludedDocsPattern}"; use a policyClassified rule with a reason.`
842
+ )
843
+ );
844
+ }
845
+ for (const policy of scope?.policyClassified ?? []) {
846
+ if (policy.paths.length === 0 || policy.reason.trim().length === 0) {
847
+ return Result.err(
848
+ new ValidationError(
849
+ 'Vocabulary Regrade policyClassified rules require paths and a reason.'
850
+ )
851
+ );
852
+ }
853
+ const excludedPolicyPath = policy.paths.find((policyPath) =>
854
+ scope?.exclude?.some(
855
+ (excludedPath) =>
856
+ excludedPath === policyPath ||
857
+ matchesAnyPathGlob(policyPath, [excludedPath]) ||
858
+ matchesAnyPathGlob(excludedPath, [policyPath])
859
+ )
860
+ );
861
+ if (excludedPolicyPath !== undefined) {
862
+ return Result.err(
863
+ new ValidationError(
864
+ `Vocabulary Regrade scope cannot both exclude and policy-classify "${excludedPolicyPath}".`
865
+ )
866
+ );
867
+ }
868
+ }
869
+ return Result.ok();
870
+ };
871
+
576
872
  const validateVocabularyPlan = (
577
873
  plan: VocabularyRegradePlan
578
874
  ): Result<void, ValidationError> => {
@@ -637,7 +933,7 @@ const validateVocabularyPlan = (
637
933
  );
638
934
  }
639
935
  }
640
- return Result.ok();
936
+ return validateVocabularyScope(plan.scope);
641
937
  };
642
938
 
643
939
  const validatePreserveInventory = (
@@ -768,6 +1064,43 @@ const preserveRuleForOccurrence = (
768
1064
  return rule.forms === undefined && pattern.test(occurrence.context);
769
1065
  });
770
1066
 
1067
+ const scopePolicyForPath = (
1068
+ path: string,
1069
+ scope: VocabularyRegradeScope | undefined
1070
+ ): VocabularyScopePolicy | undefined =>
1071
+ scope?.policyClassified?.find((policy) =>
1072
+ matchesAnyPathGlob(path, policy.paths)
1073
+ );
1074
+
1075
+ const scopePolicyForOccurrence = (
1076
+ occurrence: SourceOccurrenceDraft,
1077
+ plan: VocabularyRegradePlan
1078
+ ): VocabularyScopePolicy | undefined =>
1079
+ scopePolicyForPath(occurrence.path, plan.scope);
1080
+
1081
+ const occurrenceClassification = (
1082
+ occurrence: SourceOccurrenceDraft,
1083
+ plan: VocabularyRegradePlan
1084
+ ): {
1085
+ readonly preserveRule: VocabularyPreserveRule | undefined;
1086
+ readonly scopeTier: VocabularyScopeTier;
1087
+ } => {
1088
+ const policy = scopePolicyForOccurrence(occurrence, plan);
1089
+ const preserveRule = preserveRuleForOccurrence(occurrence, plan);
1090
+ return {
1091
+ preserveRule:
1092
+ preserveRule ??
1093
+ (policy === undefined
1094
+ ? undefined
1095
+ : {
1096
+ disposition: policy.disposition,
1097
+ pattern: occurrence.form,
1098
+ reason: policy.reason,
1099
+ }),
1100
+ scopeTier: policy === undefined ? 'in-scope' : 'policy-classified',
1101
+ };
1102
+ };
1103
+
771
1104
  const occurrenceOverlaps = (
772
1105
  occurrences: readonly {
773
1106
  readonly end: number;
@@ -807,7 +1140,10 @@ const deferredOccurrenceFromDraft = (
807
1140
  baseOccurrence: SourceOccurrenceDraft,
808
1141
  reason = 'unclassified-neighbor'
809
1142
  ): SourceOccurrence => {
810
- const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
1143
+ const { preserveRule, scopeTier } = occurrenceClassification(
1144
+ baseOccurrence,
1145
+ plan
1146
+ );
811
1147
  const markdownCodeContext = isMarkdownCodeContext(
812
1148
  file,
813
1149
  baseOccurrence.start,
@@ -832,6 +1168,7 @@ const deferredOccurrenceFromDraft = (
832
1168
  markdownCodeContext,
833
1169
  reason
834
1170
  ),
1171
+ scopeTier,
835
1172
  start: baseOccurrence.start,
836
1173
  verdict,
837
1174
  };
@@ -940,7 +1277,10 @@ const occurrencesForFile = (
940
1277
  path: file.path,
941
1278
  start,
942
1279
  };
943
- const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
1280
+ const { preserveRule, scopeTier } = occurrenceClassification(
1281
+ baseOccurrence,
1282
+ plan
1283
+ );
944
1284
  const markdownCodeContext = isMarkdownCodeContext(file, start, end);
945
1285
  const packageRouteCodeContext = isPackageRouteCodeOccurrence(
946
1286
  file.path,
@@ -981,6 +1321,7 @@ const occurrencesForFile = (
981
1321
  markdownCodeContext,
982
1322
  capturedReason
983
1323
  ),
1324
+ scopeTier,
984
1325
  ...(preserveRule === undefined &&
985
1326
  !markdownCodeContext &&
986
1327
  !packageRouteCodeContext &&
@@ -1246,21 +1587,7 @@ const buildVocabularyEvaluation = (params: {
1246
1587
  (occurrence) =>
1247
1588
  occurrence.verdict === 'modified' || occurrence.verdict === 'deferred'
1248
1589
  );
1249
- const forms: Record<string, VocabularyVerdict> = {};
1250
- for (const occurrence of occurrences) {
1251
- const current = forms[occurrence.form];
1252
- if (occurrence.verdict === 'deferred') {
1253
- forms[occurrence.form] = 'deferred';
1254
- continue;
1255
- }
1256
- if (occurrence.verdict === 'modified' && current !== 'deferred') {
1257
- forms[occurrence.form] = 'modified';
1258
- continue;
1259
- }
1260
- if (current === undefined) {
1261
- forms[occurrence.form] = 'skipped';
1262
- }
1263
- }
1590
+ const forms = vocabularyFormVerdicts(occurrences);
1264
1591
  const gateReasons: string[] = [];
1265
1592
  if (modifiedOccurrences.length > 0) {
1266
1593
  gateReasons.push(
@@ -1273,6 +1600,8 @@ const buildVocabularyEvaluation = (params: {
1273
1600
  gateReasons.push('deferred-forms-or-occurrences');
1274
1601
  }
1275
1602
  const open = unresolvedOccurrences.length;
1603
+ const scopeEvidence = vocabularyScopeEvidence(effectivePlan, occurrences);
1604
+ gateReasons.push(...scopeEvidence.gateReasons);
1276
1605
 
1277
1606
  return {
1278
1607
  entries: [
@@ -1317,7 +1646,9 @@ const buildVocabularyEvaluation = (params: {
1317
1646
  },
1318
1647
  modified: modifiedOccurrences.length,
1319
1648
  open,
1649
+ scopeTiers: scopeEvidence.scopeTiers,
1320
1650
  skipped: skippedOccurrences.length,
1651
+ teachingSurfaces: scopeEvidence.teachingSurfaces,
1321
1652
  },
1322
1653
  },
1323
1654
  scanned: scopedFiles.length,
@@ -1347,6 +1678,63 @@ const withApplySummary = (
1347
1678
  apply,
1348
1679
  });
1349
1680
 
1681
+ const appliedVocabularyRunReport = (
1682
+ postApply: VocabularyRegradeRun,
1683
+ occurrences: readonly VocabularyOccurrence[],
1684
+ apply: RegradeApplySummary
1685
+ ): VocabularyRunReport => {
1686
+ const scopeEvidence = vocabularyScopeEvidence(postApply.plan, occurrences);
1687
+ const reasons = uniqueSorted([
1688
+ ...postApply.report.gate.reasons.filter(
1689
+ (reason) =>
1690
+ reason !== 'expected-policy-classified-evidence-missing' &&
1691
+ reason !== 'expected-teaching-surfaces-missing'
1692
+ ),
1693
+ ...scopeEvidence.gateReasons,
1694
+ ]);
1695
+ return {
1696
+ ...postApply.report,
1697
+ applied: apply.applied,
1698
+ dispositions: vocabularyDispositionCounts(occurrences),
1699
+ filesChanged: apply.filesChanged,
1700
+ gate: {
1701
+ ...postApply.report.gate,
1702
+ reasons,
1703
+ status: reasons.length === 0 ? 'green' : 'open',
1704
+ },
1705
+ scopeTiers: scopeEvidence.scopeTiers,
1706
+ teachingSurfaces: scopeEvidence.teachingSurfaces,
1707
+ };
1708
+ };
1709
+
1710
+ const vocabularyRunWithAppliedOccurrences = (
1711
+ postApply: VocabularyRegradeRun,
1712
+ dryRun: VocabularyRegradeRun,
1713
+ apply: RegradeApplySummary
1714
+ ): VocabularyRegradeRun => {
1715
+ const appliedOccurrences = dryRun.ledger.occurrences
1716
+ .filter((occurrence) => occurrence.verdict === 'modified')
1717
+ .map((occurrence) => ({ ...occurrence, verdict: 'applied' as const }));
1718
+ const occurrences = [
1719
+ ...postApply.ledger.occurrences,
1720
+ ...appliedOccurrences,
1721
+ ].toSorted(
1722
+ (left, right) =>
1723
+ left.path.localeCompare(right.path) ||
1724
+ left.start - right.start ||
1725
+ left.verdict.localeCompare(right.verdict)
1726
+ );
1727
+ return {
1728
+ ...postApply,
1729
+ ledger: {
1730
+ ...postApply.ledger,
1731
+ forms: vocabularyFormVerdicts(occurrences),
1732
+ occurrences,
1733
+ },
1734
+ report: appliedVocabularyRunReport(postApply, occurrences, apply),
1735
+ };
1736
+ };
1737
+
1350
1738
  const applyVocabularyEvaluation = (
1351
1739
  files: readonly SourceFile[],
1352
1740
  evaluation: VocabularyEvaluation
@@ -1449,12 +1837,69 @@ const buildRunVocabularyEvaluation = (params: {
1449
1837
  skipped: params.skipped,
1450
1838
  });
1451
1839
 
1840
+ const ignoredDirectoriesOpenedByPolicy = (
1841
+ scope: VocabularyRegradeScope | undefined
1842
+ ): readonly string[] =>
1843
+ (scope?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES).filter(
1844
+ (directory) =>
1845
+ scope?.policyClassified?.some((policy) =>
1846
+ policy.paths.some((pattern) => pattern.split('/').includes(directory))
1847
+ )
1848
+ );
1849
+
1850
+ const vocabularyIgnoredDirectories = (
1851
+ scope: VocabularyRegradeScope | undefined
1852
+ ): readonly string[] => {
1853
+ const opened = ignoredDirectoriesOpenedByPolicy(scope);
1854
+ return (scope?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES).filter(
1855
+ (directory) => !opened.includes(directory)
1856
+ );
1857
+ };
1858
+
1859
+ const filterVocabularyCollection = (
1860
+ files: readonly { readonly absolutePath: string; readonly path: string }[],
1861
+ scope: VocabularyRegradeScope | undefined,
1862
+ sourceFilter: ((path: string) => boolean) | undefined
1863
+ ): {
1864
+ readonly files: readonly {
1865
+ readonly absolutePath: string;
1866
+ readonly path: string;
1867
+ }[];
1868
+ readonly skipped: readonly SkippedSource[];
1869
+ } => {
1870
+ const opened = ignoredDirectoriesOpenedByPolicy(scope);
1871
+ const selected = files.filter((file) => {
1872
+ const openedDirectory = file.path
1873
+ .split('/')
1874
+ .some((segment) => opened.includes(segment));
1875
+ return (
1876
+ (!openedDirectory ||
1877
+ scopePolicyForPath(file.path, scope) !== undefined) &&
1878
+ (sourceFilter?.(file.path) ?? true)
1879
+ );
1880
+ });
1881
+ const selectedPaths = new Set(selected.map((file) => file.path));
1882
+ return {
1883
+ files: selected,
1884
+ skipped: files
1885
+ .filter((file) => !selectedPaths.has(file.path))
1886
+ .map((file) => ({
1887
+ path: file.path,
1888
+ reason:
1889
+ sourceFilter?.(file.path) === false
1890
+ ? 'not-selected-source'
1891
+ : 'ignored-directory',
1892
+ })),
1893
+ };
1894
+ };
1895
+
1452
1896
  export const runVocabularyRegrade = (params: {
1453
1897
  readonly apply?: boolean;
1454
1898
  readonly includeEntries?: 'actionable' | 'all';
1455
1899
  readonly plan: VocabularyRegradePlan;
1456
1900
  readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
1457
1901
  readonly root: string;
1902
+ readonly sourceFilter?: (path: string) => boolean;
1458
1903
  }): Result<RegradeReport | null, InternalError | ValidationError> => {
1459
1904
  const planValidation = validateVocabularyPlan(params.plan);
1460
1905
  if (planValidation.isErr()) {
@@ -1480,15 +1925,23 @@ export const runVocabularyRegrade = (params: {
1480
1925
  ...(effectivePlan.scope?.include === undefined
1481
1926
  ? {}
1482
1927
  : { include: effectivePlan.scope.include }),
1483
- ...(effectivePlan.scope?.ignoredDirectories === undefined
1484
- ? {}
1485
- : { ignoredDirectories: effectivePlan.scope.ignoredDirectories }),
1928
+ ignoredDirectories: vocabularyIgnoredDirectories(effectivePlan.scope),
1486
1929
  } satisfies DownstreamCollectionOptions);
1487
1930
  if (collected === null) {
1488
1931
  return Result.ok(null);
1489
1932
  }
1490
1933
 
1491
- const { files, skipped } = readVocabularySourceFiles(collected);
1934
+ const filtered = filterVocabularyCollection(
1935
+ collected.files,
1936
+ effectivePlan.scope,
1937
+ params.sourceFilter
1938
+ );
1939
+ const { files, skipped: readSkipped } = readVocabularySourceFiles({
1940
+ ...collected,
1941
+ files: filtered.files,
1942
+ skipped: [...collected.skipped, ...filtered.skipped],
1943
+ });
1944
+ const skipped = readSkipped;
1492
1945
 
1493
1946
  const dryRunEffectiveEvaluation = buildRunVocabularyEvaluation({
1494
1947
  apply: false,
@@ -1543,14 +1996,11 @@ export const runVocabularyRegrade = (params: {
1543
1996
  run:
1544
1997
  applySummary === undefined
1545
1998
  ? reportEvaluation.run
1546
- : {
1547
- ...reportEvaluation.run,
1548
- report: {
1549
- ...reportEvaluation.run.report,
1550
- applied: applySummary.applied,
1551
- filesChanged: applySummary.filesChanged,
1552
- },
1553
- },
1999
+ : vocabularyRunWithAppliedOccurrences(
2000
+ reportEvaluation.run,
2001
+ dryRunEffectiveEvaluation.run,
2002
+ applySummary
2003
+ ),
1554
2004
  scan: buildRegradeScanSummary({
1555
2005
  matchedPaths: actionableEntries.map((entry) => entry.path),
1556
2006
  occurrencePaths: reportEvaluation.occurrences.map(
@@ -1591,6 +2041,21 @@ const vocabularyPreserveRuleSchema = z.object({
1591
2041
  reason: z.string().optional().describe('Why this form is preserved'),
1592
2042
  });
1593
2043
 
2044
+ const vocabularyScopePolicySchema = z.object({
2045
+ disposition: z
2046
+ .enum(vocabularyDispositionValues)
2047
+ .describe('Occurrence disposition assigned within this protected scope'),
2048
+ expectMatches: z
2049
+ .boolean()
2050
+ .optional()
2051
+ .describe('Require this policy scope to contribute occurrence evidence'),
2052
+ paths: z
2053
+ .array(z.string().min(1))
2054
+ .min(1)
2055
+ .describe('Root-relative protected path patterns'),
2056
+ reason: z.string().min(1).describe('Why matching files are classified'),
2057
+ });
2058
+
1594
2059
  const vocabularyPreserveInventoryEntrySchema =
1595
2060
  vocabularyPreserveRuleSchema.extend({
1596
2061
  evidence: z
@@ -1627,6 +2092,16 @@ const vocabularyRegradeScopeSchema = z.object({
1627
2092
  .array(z.string())
1628
2093
  .optional()
1629
2094
  .describe('Root-relative path patterns to include in this regrade'),
2095
+ policyClassified: z
2096
+ .array(vocabularyScopePolicySchema)
2097
+ .optional()
2098
+ .describe(
2099
+ 'Protected paths that remain scanned and counted but are never rewritten by default'
2100
+ ),
2101
+ teachingSurfaces: z
2102
+ .array(z.string().min(1))
2103
+ .optional()
2104
+ .describe('Expected current teaching-surface path patterns'),
1630
2105
  });
1631
2106
 
1632
2107
  export const vocabularyRegradePlanSchema = z.object({
@@ -1638,6 +2113,15 @@ export const vocabularyRegradePlanSchema = z.object({
1638
2113
  .array(z.string().min(1))
1639
2114
  .optional()
1640
2115
  .describe('Known forms that must be inventoried for review, not rewritten'),
2116
+ fileRenames: z
2117
+ .array(
2118
+ z.object({
2119
+ from: z.string().min(1).describe('Root-relative source file path'),
2120
+ to: z.string().min(1).describe('Root-relative target file path'),
2121
+ })
2122
+ )
2123
+ .optional()
2124
+ .describe('Governed file moves whose references are derived from scope'),
1641
2125
  from: z.string().min(1).describe('Source vocabulary term or phrase'),
1642
2126
  id: z.string().optional().describe('Stable authored regrade plan id'),
1643
2127
  intent: z.string().optional().describe('Human-authored migration intent'),
@@ -1661,7 +2145,10 @@ export const vocabularyRegradeRunOutput = z.object({
1661
2145
  .object({
1662
2146
  cycle: z.number().describe('Observed regrade run cycle'),
1663
2147
  forms: z
1664
- .record(z.string(), z.enum(['deferred', 'modified', 'skipped']))
2148
+ .record(
2149
+ z.string(),
2150
+ z.enum(['applied', 'deferred', 'modified', 'skipped'])
2151
+ )
1665
2152
  .describe('Observed per-form triage verdicts for this run'),
1666
2153
  occurrences: z
1667
2154
  .array(
@@ -1680,9 +2167,12 @@ export const vocabularyRegradeRunOutput = z.object({
1680
2167
  .string()
1681
2168
  .optional()
1682
2169
  .describe('Replacement text for modified verdicts'),
2170
+ scopeTier: z
2171
+ .enum(['in-scope', 'policy-classified'])
2172
+ .describe('Three-tier scope classification for this occurrence'),
1683
2173
  start: z.number().describe('Source start offset'),
1684
2174
  verdict: z
1685
- .enum(['deferred', 'modified', 'skipped'])
2175
+ .enum(['applied', 'deferred', 'modified', 'skipped'])
1686
2176
  .describe('Occurrence-level verdict'),
1687
2177
  })
1688
2178
  )
@@ -1703,6 +2193,20 @@ export const vocabularyRegradeRunOutput = z.object({
1703
2193
  dispositions: z
1704
2194
  .object(vocabularyDispositionCountSchema.shape)
1705
2195
  .describe('Occurrence counts grouped by disposition'),
2196
+ fileRenames: z
2197
+ .array(
2198
+ z.object({
2199
+ deferred: z.number(),
2200
+ from: z.string(),
2201
+ historical: z.number(),
2202
+ preserved: z.number(),
2203
+ rewritten: z.number(),
2204
+ skipped: z.number(),
2205
+ to: z.string(),
2206
+ })
2207
+ )
2208
+ .optional()
2209
+ .describe('Governed file moves and derived reference outcomes'),
1706
2210
  filesChanged: z.number().describe('Distinct files changed on disk'),
1707
2211
  gate: z
1708
2212
  .object({
@@ -1722,9 +2226,22 @@ export const vocabularyRegradeRunOutput = z.object({
1722
2226
  .describe(
1723
2227
  'Deferred or unapplied modified occurrences holding the gate open'
1724
2228
  ),
2229
+ scopeTiers: z
2230
+ .object({
2231
+ 'in-scope': z.number(),
2232
+ 'policy-classified': z.number(),
2233
+ })
2234
+ .describe('Occurrence counts grouped by scope tier'),
1725
2235
  skipped: z.number().describe('Skipped occurrence count'),
2236
+ teachingSurfaces: z
2237
+ .object({
2238
+ expected: z.array(z.string()),
2239
+ missing: z.array(z.string()),
2240
+ touched: z.array(z.string()),
2241
+ })
2242
+ .describe('Expected and observed current teaching surfaces'),
1726
2243
  })
1727
- .describe('Projected run report'),
2244
+ .describe('Derived run report'),
1728
2245
  });
1729
2246
 
1730
2247
  const vocabularyTransitionRecordEnvironmentSchema = z
@@ -1784,6 +2301,81 @@ export const vocabularyTransitionRecordSchema = z
1784
2301
  })
1785
2302
  .strict();
1786
2303
 
2304
+ const normalizeLegacyTransitionRecordScopeEvidence = (
2305
+ value: unknown
2306
+ ): unknown => {
2307
+ if (
2308
+ !isPlainObject(value) ||
2309
+ value['schemaVersion'] !== VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION
2310
+ ) {
2311
+ return value;
2312
+ }
2313
+ const { report } = value;
2314
+ if (!isPlainObject(report)) {
2315
+ return value;
2316
+ }
2317
+ const { run } = report;
2318
+ if (!isPlainObject(run)) {
2319
+ return value;
2320
+ }
2321
+ const { ledger, report: runReport } = run;
2322
+ if (
2323
+ !isPlainObject(ledger) ||
2324
+ !Array.isArray(ledger['occurrences']) ||
2325
+ !isPlainObject(runReport)
2326
+ ) {
2327
+ return value;
2328
+ }
2329
+ const plan = vocabularyRegradePlanSchema.safeParse(run['plan']);
2330
+ if (!plan.success) {
2331
+ return value;
2332
+ }
2333
+ const vocabularyPlan = plan.data as VocabularyRegradePlan;
2334
+ const occurrences = ledger['occurrences'].map((occurrence) =>
2335
+ isPlainObject(occurrence) &&
2336
+ typeof occurrence['path'] === 'string' &&
2337
+ occurrence['scopeTier'] === undefined
2338
+ ? {
2339
+ ...occurrence,
2340
+ scopeTier:
2341
+ scopePolicyForPath(occurrence['path'], vocabularyPlan.scope) ===
2342
+ undefined
2343
+ ? 'in-scope'
2344
+ : 'policy-classified',
2345
+ }
2346
+ : occurrence
2347
+ );
2348
+ const scopeEvidence = vocabularyScopeEvidence(
2349
+ vocabularyPlan,
2350
+ occurrences.filter(
2351
+ (occurrence) =>
2352
+ isPlainObject(occurrence) &&
2353
+ typeof occurrence['path'] === 'string' &&
2354
+ (occurrence['scopeTier'] === 'in-scope' ||
2355
+ occurrence['scopeTier'] === 'policy-classified')
2356
+ ) as unknown as readonly VocabularyOccurrence[]
2357
+ );
2358
+ return {
2359
+ ...value,
2360
+ report: {
2361
+ ...report,
2362
+ run: {
2363
+ ...run,
2364
+ ledger: { ...ledger, occurrences },
2365
+ report: {
2366
+ ...runReport,
2367
+ ...(runReport['scopeTiers'] === undefined
2368
+ ? { scopeTiers: scopeEvidence.scopeTiers }
2369
+ : {}),
2370
+ ...(runReport['teachingSurfaces'] === undefined
2371
+ ? { teachingSurfaces: scopeEvidence.teachingSurfaces }
2372
+ : {}),
2373
+ },
2374
+ },
2375
+ },
2376
+ };
2377
+ };
2378
+
1787
2379
  const transitionRecordSlug = (run: VocabularyRegradeRun): string =>
1788
2380
  `${run.plan.from}-to-${run.plan.to}`
1789
2381
  .toLowerCase()
@@ -1990,7 +2582,9 @@ export const readVocabularyTransitionRecord = (
1990
2582
  })
1991
2583
  );
1992
2584
  }
1993
- const parsed = vocabularyTransitionRecordSchema.safeParse(parsedJson);
2585
+ const parsed = vocabularyTransitionRecordSchema.safeParse(
2586
+ normalizeLegacyTransitionRecordScopeEvidence(parsedJson)
2587
+ );
1994
2588
  if (!parsed.success) {
1995
2589
  return Result.err(
1996
2590
  new ValidationError('Invalid vocabulary transition record.', {