@ontrails/regrade 1.0.0-beta.45 → 1.0.0-beta.47

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.
@@ -6,6 +6,8 @@ import {
6
6
  isPlainObject,
7
7
  matchesAnyPathGlob,
8
8
  } from '@ontrails/core';
9
+ import { parseWithDiagnostics } from '@ontrails/source';
10
+ import type { SourceComment } from '@ontrails/source';
9
11
  import { createHash } from 'node:crypto';
10
12
  import {
11
13
  dirname,
@@ -24,6 +26,7 @@ import {
24
26
  } from './collect.js';
25
27
  import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
26
28
  import type {
29
+ PreparedRegradeRunIdentity,
27
30
  RegradeApplySummary,
28
31
  RegradeReport,
29
32
  RegradeReportEntry,
@@ -32,6 +35,8 @@ import { buildRegradeScanSummary } from './scan-summary.js';
32
35
 
33
36
  export type VocabularyVerdict = 'applied' | 'deferred' | 'modified' | 'skipped';
34
37
 
38
+ export type VocabularyOccurrenceSourceKind = 'source-comment' | 'tsdoc';
39
+
35
40
  export const vocabularyDispositionValues = [
36
41
  'code-context-out-of-engine',
37
42
  'docs-only',
@@ -170,6 +175,7 @@ export interface VocabularyOccurrence {
170
175
  readonly replacement?: string;
171
176
  readonly start: number;
172
177
  readonly scopeTier: VocabularyScopeTier;
178
+ readonly sourceKind?: VocabularyOccurrenceSourceKind;
173
179
  readonly verdict: VocabularyVerdict;
174
180
  }
175
181
 
@@ -247,12 +253,23 @@ interface SourceFile {
247
253
  readonly absolutePath: string;
248
254
  readonly path: string;
249
255
  readonly source: string;
256
+ readonly sourceBytes: string;
250
257
  }
251
258
 
252
259
  interface SourceOccurrence extends VocabularyOccurrence {
253
260
  readonly absolutePath: string;
254
261
  }
255
262
 
263
+ type VocabularySourceKind = 'all' | 'comments';
264
+
265
+ const sourceCommentKind = (
266
+ file: SourceFile,
267
+ comment: SourceComment
268
+ ): VocabularyOccurrenceSourceKind =>
269
+ comment.type === 'Block' && file.source.startsWith('/**', comment.start)
270
+ ? 'tsdoc'
271
+ : 'source-comment';
272
+
256
273
  interface SourceOccurrenceDraft extends Omit<
257
274
  SourceOccurrence,
258
275
  'disposition' | 'reason' | 'scopeTier' | 'verdict'
@@ -450,7 +467,16 @@ const lineColumnForOffset = (
450
467
  let line = 1;
451
468
  let column = 1;
452
469
  for (let index = 0; index < offset; index += 1) {
453
- if (source.codePointAt(index) === 10) {
470
+ const codePoint = source.codePointAt(index);
471
+ if (
472
+ codePoint === 10 ||
473
+ codePoint === 13 ||
474
+ codePoint === 0x20_28 ||
475
+ codePoint === 0x20_29
476
+ ) {
477
+ if (codePoint === 13 && source.codePointAt(index + 1) === 10) {
478
+ index += 1;
479
+ }
454
480
  line += 1;
455
481
  column = 1;
456
482
  } else {
@@ -465,9 +491,32 @@ const contextDetailsForOffset = (
465
491
  start: number,
466
492
  end: number
467
493
  ): { readonly context: string; readonly contextColumn: number } => {
468
- const lineStart = source.lastIndexOf('\n', start - 1) + 1;
469
- const nextLine = source.indexOf('\n', end);
470
- const lineEnd = nextLine === -1 ? source.length : nextLine;
494
+ let lineStart = start;
495
+ while (lineStart > 0) {
496
+ const codePoint = source.codePointAt(lineStart - 1);
497
+ if (
498
+ codePoint === 10 ||
499
+ codePoint === 13 ||
500
+ codePoint === 0x20_28 ||
501
+ codePoint === 0x20_29
502
+ ) {
503
+ break;
504
+ }
505
+ lineStart -= 1;
506
+ }
507
+ let lineEnd = end;
508
+ while (lineEnd < source.length) {
509
+ const codePoint = source.codePointAt(lineEnd);
510
+ if (
511
+ codePoint === 10 ||
512
+ codePoint === 13 ||
513
+ codePoint === 0x20_28 ||
514
+ codePoint === 0x20_29
515
+ ) {
516
+ break;
517
+ }
518
+ lineEnd += 1;
519
+ }
471
520
  const rawLine = source.slice(lineStart, lineEnd);
472
521
  const leadingTrimmed = rawLine.length - rawLine.trimStart().length;
473
522
  return {
@@ -735,6 +784,21 @@ const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
735
784
  ]);
736
785
  };
737
786
 
787
+ const titleCaseVocabularyForm = (form: string): string => {
788
+ const first = form.at(0);
789
+ return first === undefined ? form : `${first.toUpperCase()}${form.slice(1)}`;
790
+ };
791
+
792
+ const commentReviewPlan = (
793
+ plan: VocabularyRegradePlan
794
+ ): VocabularyRegradePlan => ({
795
+ ...plan,
796
+ deferForms: uniqueSorted([
797
+ ...(plan.deferForms ?? []),
798
+ ...(plan.deferForms ?? []).map(titleCaseVocabularyForm),
799
+ ]),
800
+ });
801
+
738
802
  /**
739
803
  * Synthesize the deterministic form proposal carried by a minimal plan seed.
740
804
  *
@@ -1471,8 +1535,19 @@ const entryForOccurrences = (
1471
1535
  reviewDetails: occurrences
1472
1536
  .filter((occurrence) => occurrence.verdict === 'deferred')
1473
1537
  .map((occurrence) => ({
1538
+ context: occurrence.context,
1474
1539
  expectedTarget:
1475
1540
  'Add an override or preserve rule to the regrade plan.',
1541
+ judgment: 'unresolved' as const,
1542
+ matchedForm: occurrence.form,
1543
+ ...(occurrence.sourceKind === undefined
1544
+ ? {}
1545
+ : {
1546
+ nodeKind:
1547
+ occurrence.sourceKind === 'tsdoc'
1548
+ ? 'TSDocComment'
1549
+ : 'SourceComment',
1550
+ }),
1476
1551
  reason: occurrence.reason,
1477
1552
  span: {
1478
1553
  column: occurrence.column,
@@ -1528,6 +1603,9 @@ const buildVocabularyEvaluation = (params: {
1528
1603
  readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
1529
1604
  readonly root: string;
1530
1605
  readonly skipped: readonly SkippedSource[];
1606
+ readonly sourceKindForPath?:
1607
+ | ((path: string) => VocabularySourceKind)
1608
+ | undefined;
1531
1609
  }): VocabularyEvaluation => {
1532
1610
  const effectivePlan = params.effectivePlan ?? params.plan;
1533
1611
  const targetForms = targetFormsForPlan(effectivePlan);
@@ -1537,21 +1615,59 @@ const buildVocabularyEvaluation = (params: {
1537
1615
  const scopeSkipped: SkippedSource[] = params.files
1538
1616
  .filter((file) => !includedByScope(file.path, effectivePlan.scope))
1539
1617
  .map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
1618
+ const commentParseSkipped: SkippedSource[] = [];
1540
1619
  const occurrences = scopedFiles.flatMap((file) => {
1620
+ const sourceKind = params.sourceKindForPath?.(file.path) ?? 'all';
1621
+ const scanPlan =
1622
+ sourceKind === 'comments'
1623
+ ? commentReviewPlan(effectivePlan)
1624
+ : effectivePlan;
1541
1625
  const deferredOccurrences = deferredOccurrencesForFile(
1542
1626
  file,
1543
- effectivePlan,
1627
+ scanPlan,
1544
1628
  targetForms
1545
1629
  );
1546
- return [
1547
- ...occurrencesForFile(
1548
- file,
1549
- effectivePlan,
1550
- targetForms,
1551
- deferredOccurrences
1552
- ),
1630
+ const fileOccurrences = [
1631
+ ...occurrencesForFile(file, scanPlan, targetForms, deferredOccurrences),
1553
1632
  ...deferredOccurrences,
1554
1633
  ];
1634
+ if (sourceKind === 'all') {
1635
+ return fileOccurrences;
1636
+ }
1637
+ if (fileOccurrences.length === 0) {
1638
+ return [];
1639
+ }
1640
+ const parsed = parseWithDiagnostics(file.path, file.source);
1641
+ if (parsed.diagnostics.length > 0 || parsed.ast === null) {
1642
+ commentParseSkipped.push({
1643
+ path: file.path,
1644
+ reason: 'source-comment-parse-diagnostics',
1645
+ });
1646
+ return [];
1647
+ }
1648
+ return fileOccurrences.flatMap((occurrence) => {
1649
+ const comment = parsed.comments.find(
1650
+ (candidate) =>
1651
+ candidate.start <= occurrence.start && occurrence.end <= candidate.end
1652
+ );
1653
+ if (comment === undefined) {
1654
+ return [];
1655
+ }
1656
+ const sourceKindForOccurrence = sourceCommentKind(file, comment);
1657
+ if (occurrence.verdict === 'skipped') {
1658
+ return [{ ...occurrence, sourceKind: sourceKindForOccurrence }];
1659
+ }
1660
+ const { replacement: _replacement, ...reviewOccurrence } = occurrence;
1661
+ return [
1662
+ {
1663
+ ...reviewOccurrence,
1664
+ disposition: 'in-family-unresolved' as const,
1665
+ reason: 'source-comment-requires-review',
1666
+ sourceKind: sourceKindForOccurrence,
1667
+ verdict: 'deferred' as const,
1668
+ },
1669
+ ];
1670
+ });
1555
1671
  });
1556
1672
  const occurrencesByPath = new Map<string, SourceOccurrence[]>();
1557
1673
  for (const occurrence of occurrences) {
@@ -1599,6 +1715,9 @@ const buildVocabularyEvaluation = (params: {
1599
1715
  if (deferredForms.length > 0) {
1600
1716
  gateReasons.push('deferred-forms-or-occurrences');
1601
1717
  }
1718
+ if (commentParseSkipped.length > 0) {
1719
+ gateReasons.push('source-comment-parse-diagnostics');
1720
+ }
1602
1721
  const open = unresolvedOccurrences.length;
1603
1722
  const scopeEvidence = vocabularyScopeEvidence(effectivePlan, occurrences);
1604
1723
  gateReasons.push(...scopeEvidence.gateReasons);
@@ -1616,6 +1735,11 @@ const buildVocabularyEvaluation = (params: {
1616
1735
  path: entry.path,
1617
1736
  reason: entry.reason,
1618
1737
  })),
1738
+ ...commentParseSkipped.map((entry) => ({
1739
+ outcome: 'skip' as const,
1740
+ path: entry.path,
1741
+ reason: entry.reason,
1742
+ })),
1619
1743
  ].toSorted((left, right) => left.path.localeCompare(right.path)),
1620
1744
  occurrences,
1621
1745
  run: {
@@ -1652,7 +1776,7 @@ const buildVocabularyEvaluation = (params: {
1652
1776
  },
1653
1777
  },
1654
1778
  scanned: scopedFiles.length,
1655
- skipped: [...params.skipped, ...scopeSkipped],
1779
+ skipped: [...params.skipped, ...scopeSkipped, ...commentParseSkipped],
1656
1780
  };
1657
1781
  };
1658
1782
 
@@ -1670,13 +1794,37 @@ const skippedByReason = (
1670
1794
  );
1671
1795
  };
1672
1796
 
1797
+ const withScannedPaths = (
1798
+ report: RegradeReport,
1799
+ paths: readonly string[]
1800
+ ): RegradeReport => {
1801
+ Object.defineProperty(report, 'scannedPaths', {
1802
+ configurable: false,
1803
+ enumerable: false,
1804
+ value: Object.freeze([...paths]),
1805
+ writable: false,
1806
+ });
1807
+ return report;
1808
+ };
1809
+
1810
+ const cloneVocabularyReport = (report: RegradeReport): RegradeReport => {
1811
+ const clone = structuredClone(report);
1812
+ return report.scannedPaths === undefined
1813
+ ? clone
1814
+ : withScannedPaths(clone, report.scannedPaths);
1815
+ };
1816
+
1673
1817
  const withApplySummary = (
1674
1818
  report: RegradeReport,
1675
1819
  apply: RegradeApplySummary
1676
- ): RegradeReport => ({
1677
- ...report,
1678
- apply,
1679
- });
1820
+ ): RegradeReport =>
1821
+ withScannedPaths(
1822
+ {
1823
+ ...report,
1824
+ apply,
1825
+ },
1826
+ report.scannedPaths ?? []
1827
+ );
1680
1828
 
1681
1829
  const appliedVocabularyRunReport = (
1682
1830
  postApply: VocabularyRegradeRun,
@@ -1802,10 +1950,12 @@ const readVocabularySourceFiles = (
1802
1950
  const skipped: SkippedSource[] = [...collected.skipped];
1803
1951
  for (const file of collected.files) {
1804
1952
  try {
1953
+ const bytes = readFileSync(file.absolutePath);
1805
1954
  files.push({
1806
1955
  absolutePath: file.absolutePath,
1807
1956
  path: file.path,
1808
- source: readFileSync(file.absolutePath, 'utf8'),
1957
+ source: bytes.toString('utf8'),
1958
+ sourceBytes: bytes.toString('base64'),
1809
1959
  });
1810
1960
  } catch {
1811
1961
  skipped.push({ path: file.path, reason: 'unreadable-file' });
@@ -1824,6 +1974,9 @@ const buildRunVocabularyEvaluation = (params: {
1824
1974
  | undefined;
1825
1975
  readonly root: string;
1826
1976
  readonly skipped: readonly SkippedSource[];
1977
+ readonly sourceKindForPath?:
1978
+ | ((path: string) => VocabularySourceKind)
1979
+ | undefined;
1827
1980
  }): VocabularyEvaluation =>
1828
1981
  buildVocabularyEvaluation({
1829
1982
  apply: params.apply,
@@ -1835,6 +1988,7 @@ const buildRunVocabularyEvaluation = (params: {
1835
1988
  : { preserveInventory: params.preserveInventory }),
1836
1989
  root: params.root,
1837
1990
  skipped: params.skipped,
1991
+ sourceKindForPath: params.sourceKindForPath,
1838
1992
  });
1839
1993
 
1840
1994
  const ignoredDirectoriesOpenedByPolicy = (
@@ -1893,30 +2047,48 @@ const filterVocabularyCollection = (
1893
2047
  };
1894
2048
  };
1895
2049
 
1896
- export const runVocabularyRegrade = (params: {
1897
- readonly apply?: boolean;
2050
+ interface VocabularyRunInputs {
2051
+ readonly collectedRoot: string;
2052
+ readonly effectivePlan: VocabularyRegradePlan;
2053
+ readonly files: readonly SourceFile[];
2054
+ readonly skipped: readonly SkippedSource[];
2055
+ }
2056
+
2057
+ interface PrepareVocabularyRegradeRunParams {
1898
2058
  readonly includeEntries?: 'actionable' | 'all';
1899
2059
  readonly plan: VocabularyRegradePlan;
1900
2060
  readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
1901
2061
  readonly root: string;
1902
2062
  readonly sourceFilter?: (path: string) => boolean;
1903
- }): Result<RegradeReport | null, InternalError | ValidationError> => {
1904
- const planValidation = validateVocabularyPlan(params.plan);
1905
- if (planValidation.isErr()) {
1906
- return planValidation;
1907
- }
1908
- const inventoryValidation = validatePreserveInventory(
1909
- params.preserveInventory
1910
- );
1911
- if (inventoryValidation.isErr()) {
1912
- return inventoryValidation;
1913
- }
2063
+ readonly sourceKindForPath?: (path: string) => VocabularySourceKind;
2064
+ }
2065
+
2066
+ const snapshotPrepareVocabularyRegradeRunParams = (
2067
+ params: PrepareVocabularyRegradeRunParams
2068
+ ): PrepareVocabularyRegradeRunParams => ({
2069
+ ...(params.includeEntries === undefined
2070
+ ? {}
2071
+ : { includeEntries: params.includeEntries }),
2072
+ plan: structuredClone(params.plan),
2073
+ ...(params.preserveInventory === undefined
2074
+ ? {}
2075
+ : { preserveInventory: structuredClone(params.preserveInventory) }),
2076
+ root: params.root,
2077
+ ...(params.sourceFilter === undefined
2078
+ ? {}
2079
+ : { sourceFilter: params.sourceFilter }),
2080
+ ...(params.sourceKindForPath === undefined
2081
+ ? {}
2082
+ : { sourceKindForPath: params.sourceKindForPath }),
2083
+ });
1914
2084
 
2085
+ const collectVocabularyRunInputs = (
2086
+ params: PrepareVocabularyRegradeRunParams
2087
+ ): VocabularyRunInputs | null => {
1915
2088
  const effectivePlan = effectivePlanForRun(
1916
2089
  params.plan,
1917
2090
  params.preserveInventory
1918
2091
  );
1919
-
1920
2092
  const collected = collectDownstreamSources(params.root, {
1921
2093
  extensions: effectivePlan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
1922
2094
  ...(effectivePlan.scope?.exclude === undefined
@@ -1928,20 +2100,365 @@ export const runVocabularyRegrade = (params: {
1928
2100
  ignoredDirectories: vocabularyIgnoredDirectories(effectivePlan.scope),
1929
2101
  } satisfies DownstreamCollectionOptions);
1930
2102
  if (collected === null) {
1931
- return Result.ok(null);
2103
+ return null;
1932
2104
  }
1933
-
1934
2105
  const filtered = filterVocabularyCollection(
1935
2106
  collected.files,
1936
2107
  effectivePlan.scope,
1937
2108
  params.sourceFilter
1938
2109
  );
1939
- const { files, skipped: readSkipped } = readVocabularySourceFiles({
2110
+ const { files, skipped } = readVocabularySourceFiles({
1940
2111
  ...collected,
1941
2112
  files: filtered.files,
1942
2113
  skipped: [...collected.skipped, ...filtered.skipped],
1943
2114
  });
1944
- const skipped = readSkipped;
2115
+ return { collectedRoot: collected.root, effectivePlan, files, skipped };
2116
+ };
2117
+
2118
+ const vocabularyReportFromEvaluation = (params: {
2119
+ readonly applySummary?: RegradeApplySummary;
2120
+ readonly collectedRoot: string;
2121
+ readonly dryRunEvaluation: VocabularyEvaluation;
2122
+ readonly effectivePlan: VocabularyRegradePlan;
2123
+ readonly entrySelection: 'actionable' | 'all';
2124
+ readonly files: readonly SourceFile[];
2125
+ readonly plan: VocabularyRegradePlan;
2126
+ readonly reportEvaluation: VocabularyEvaluation;
2127
+ readonly skipped: readonly SkippedSource[];
2128
+ }): RegradeReport => {
2129
+ const reportEntries = params.reportEvaluation.entries;
2130
+ const actionableEntries = reportEntries.filter(
2131
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
2132
+ );
2133
+ const reportSkippedByReason = skippedByReason(
2134
+ params.reportEvaluation.skipped
2135
+ );
2136
+ const report: RegradeReport = withScannedPaths(
2137
+ {
2138
+ entries:
2139
+ params.entrySelection === 'all' ? reportEntries : actionableEntries,
2140
+ matched: actionableEntries.length,
2141
+ review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
2142
+ .length,
2143
+ rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
2144
+ .length,
2145
+ root: params.collectedRoot,
2146
+ run:
2147
+ params.applySummary === undefined
2148
+ ? params.reportEvaluation.run
2149
+ : vocabularyRunWithAppliedOccurrences(
2150
+ params.reportEvaluation.run,
2151
+ params.dryRunEvaluation.run,
2152
+ params.applySummary
2153
+ ),
2154
+ scan: buildRegradeScanSummary({
2155
+ matchedPaths: actionableEntries.map((entry) => entry.path),
2156
+ occurrencePaths: params.reportEvaluation.occurrences.map(
2157
+ (occurrence) => occurrence.path
2158
+ ),
2159
+ scanned: params.reportEvaluation.scanned,
2160
+ skipped: params.reportEvaluation.skipped.length,
2161
+ skippedByReason: reportSkippedByReason,
2162
+ }),
2163
+ scanned: params.reportEvaluation.scanned,
2164
+ selectedClassIds: [
2165
+ params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
2166
+ ],
2167
+ skipped: params.reportEvaluation.skipped.length,
2168
+ skipsByReason: reportSkippedByReason,
2169
+ unknownClassIds: [],
2170
+ },
2171
+ params.files
2172
+ .filter((file) => includedByScope(file.path, params.effectivePlan.scope))
2173
+ .map((file) => file.path)
2174
+ );
2175
+ return params.applySummary === undefined
2176
+ ? report
2177
+ : withApplySummary(report, params.applySummary);
2178
+ };
2179
+
2180
+ /**
2181
+ * In-memory vocabulary evaluation ready for freshness-checked apply.
2182
+ *
2183
+ * @example
2184
+ * ```ts
2185
+ * const prepared = prepareVocabularyRegradeRun({ identity, plan, root });
2186
+ * if (prepared.isOk() && prepared.value !== null) {
2187
+ * console.log(prepared.value.report.run?.gate.status);
2188
+ * }
2189
+ * ```
2190
+ */
2191
+ export interface PreparedVocabularyRegradeRun {
2192
+ readonly identity: PreparedRegradeRunIdentity;
2193
+ readonly report: RegradeReport;
2194
+ readonly sourceStateHash: string;
2195
+ }
2196
+
2197
+ interface PreparedVocabularyRegradeRunState {
2198
+ readonly dryRunEvaluation: VocabularyEvaluation;
2199
+ readonly identity: PreparedRegradeRunIdentity;
2200
+ readonly inputs: VocabularyRunInputs;
2201
+ readonly params: PrepareVocabularyRegradeRunParams;
2202
+ readonly sourceStateHash: string;
2203
+ }
2204
+
2205
+ const preparedVocabularyRunStates = new WeakMap<
2206
+ PreparedVocabularyRegradeRun,
2207
+ PreparedVocabularyRegradeRunState
2208
+ >();
2209
+
2210
+ const vocabularySourceStateHash = (files: readonly SourceFile[]): string =>
2211
+ createHash('sha256')
2212
+ .update(
2213
+ JSON.stringify({
2214
+ sources: files
2215
+ .map((file) => ({ bytes: file.sourceBytes, path: file.path }))
2216
+ .toSorted((left, right) => {
2217
+ if (left.path < right.path) {
2218
+ return -1;
2219
+ }
2220
+ if (left.path > right.path) {
2221
+ return 1;
2222
+ }
2223
+ return 0;
2224
+ }),
2225
+ })
2226
+ )
2227
+ .digest('hex');
2228
+
2229
+ const unreadableVocabularyPaths = (
2230
+ skipped: readonly SkippedSource[]
2231
+ ): readonly string[] =>
2232
+ skipped
2233
+ .filter(
2234
+ (entry) =>
2235
+ entry.reason === 'unreadable-file' ||
2236
+ entry.reason === 'unreadable-directory'
2237
+ )
2238
+ .map((entry) => entry.path);
2239
+
2240
+ const validateVocabularyPreparedIdentity = (
2241
+ expected: PreparedRegradeRunIdentity,
2242
+ actual: PreparedRegradeRunIdentity
2243
+ ): Result<void, ValidationError> => {
2244
+ for (const field of [
2245
+ 'planContentHash',
2246
+ 'policyHash',
2247
+ 'scopeHash',
2248
+ 'lockStateHash',
2249
+ 'toolVersion',
2250
+ ] as const) {
2251
+ if (expected[field] !== actual[field]) {
2252
+ return Result.err(
2253
+ new ValidationError(
2254
+ `Prepared vocabulary Regrade identity field \`${field}\` is stale.`,
2255
+ {
2256
+ context: {
2257
+ actual: actual[field],
2258
+ expected: expected[field],
2259
+ field,
2260
+ },
2261
+ }
2262
+ )
2263
+ );
2264
+ }
2265
+ }
2266
+ return Result.ok();
2267
+ };
2268
+
2269
+ /**
2270
+ * Classify a vocabulary Regrade run once and retain it for checked apply.
2271
+ *
2272
+ * @example
2273
+ * ```ts
2274
+ * const prepared = prepareVocabularyRegradeRun({ identity, plan, root });
2275
+ * if (prepared.isErr()) throw prepared.error;
2276
+ * ```
2277
+ */
2278
+ export const prepareVocabularyRegradeRun = (
2279
+ params: PrepareVocabularyRegradeRunParams & {
2280
+ readonly identity: PreparedRegradeRunIdentity;
2281
+ }
2282
+ ): Result<PreparedVocabularyRegradeRun | null, ValidationError> => {
2283
+ const preparedParams = snapshotPrepareVocabularyRegradeRunParams(params);
2284
+ const planValidation = validateVocabularyPlan(preparedParams.plan);
2285
+ if (planValidation.isErr()) {
2286
+ return planValidation;
2287
+ }
2288
+ const inventoryValidation = validatePreserveInventory(
2289
+ preparedParams.preserveInventory
2290
+ );
2291
+ if (inventoryValidation.isErr()) {
2292
+ return inventoryValidation;
2293
+ }
2294
+ const inputs = collectVocabularyRunInputs(preparedParams);
2295
+ if (inputs === null) {
2296
+ return Result.ok(null);
2297
+ }
2298
+ const unreadable = unreadableVocabularyPaths(inputs.skipped);
2299
+ if (unreadable.length > 0) {
2300
+ return Result.err(
2301
+ new ValidationError(
2302
+ 'Prepared vocabulary Regrade sources must all be readable.',
2303
+ { context: { paths: unreadable } }
2304
+ )
2305
+ );
2306
+ }
2307
+ const dryRunEvaluation = buildRunVocabularyEvaluation({
2308
+ apply: false,
2309
+ effectivePlan: inputs.effectivePlan,
2310
+ files: inputs.files,
2311
+ plan: preparedParams.plan,
2312
+ preserveInventory: preparedParams.preserveInventory,
2313
+ root: preparedParams.root,
2314
+ skipped: inputs.skipped,
2315
+ sourceKindForPath: preparedParams.sourceKindForPath,
2316
+ });
2317
+ const report = vocabularyReportFromEvaluation({
2318
+ collectedRoot: inputs.collectedRoot,
2319
+ dryRunEvaluation,
2320
+ effectivePlan: inputs.effectivePlan,
2321
+ entrySelection: preparedParams.includeEntries ?? 'actionable',
2322
+ files: inputs.files,
2323
+ plan: preparedParams.plan,
2324
+ reportEvaluation: dryRunEvaluation,
2325
+ skipped: inputs.skipped,
2326
+ });
2327
+ const prepared: PreparedVocabularyRegradeRun = {
2328
+ identity: { ...params.identity },
2329
+ report: cloneVocabularyReport(report),
2330
+ sourceStateHash: vocabularySourceStateHash(inputs.files),
2331
+ };
2332
+ preparedVocabularyRunStates.set(prepared, {
2333
+ dryRunEvaluation,
2334
+ identity: { ...params.identity },
2335
+ inputs,
2336
+ params: preparedParams,
2337
+ sourceStateHash: prepared.sourceStateHash,
2338
+ });
2339
+ return Result.ok(prepared);
2340
+ };
2341
+
2342
+ /**
2343
+ * Apply an in-memory vocabulary evaluation after identity and source checks.
2344
+ *
2345
+ * @example
2346
+ * ```ts
2347
+ * const applied = applyPreparedVocabularyRegradeRun(prepared, identity);
2348
+ * if (applied.isErr()) throw applied.error;
2349
+ * ```
2350
+ */
2351
+ export const applyPreparedVocabularyRegradeRun = (
2352
+ prepared: PreparedVocabularyRegradeRun,
2353
+ identity: PreparedRegradeRunIdentity
2354
+ ): Result<RegradeReport, InternalError | ValidationError> => {
2355
+ const state = preparedVocabularyRunStates.get(prepared);
2356
+ if (state === undefined) {
2357
+ return Result.err(
2358
+ new ValidationError(
2359
+ 'Prepared vocabulary Regrade run is not the original in-memory evaluation.'
2360
+ )
2361
+ );
2362
+ }
2363
+ const identityValidation = validateVocabularyPreparedIdentity(
2364
+ state.identity,
2365
+ identity
2366
+ );
2367
+ if (identityValidation.isErr()) {
2368
+ return identityValidation;
2369
+ }
2370
+ const current = collectVocabularyRunInputs(state.params);
2371
+ if (current === null) {
2372
+ return Result.err(
2373
+ new ValidationError(
2374
+ 'Prepared vocabulary Regrade root is no longer readable.'
2375
+ )
2376
+ );
2377
+ }
2378
+ const unreadable = unreadableVocabularyPaths(current.skipped);
2379
+ if (unreadable.length > 0) {
2380
+ return Result.err(
2381
+ new ValidationError(
2382
+ 'Prepared vocabulary Regrade sources are no longer readable.',
2383
+ { context: { paths: unreadable } }
2384
+ )
2385
+ );
2386
+ }
2387
+ const currentSourceStateHash = vocabularySourceStateHash(current.files);
2388
+ if (currentSourceStateHash !== state.sourceStateHash) {
2389
+ return Result.err(
2390
+ new ValidationError(
2391
+ 'Prepared vocabulary Regrade source state is stale.',
2392
+ {
2393
+ context: {
2394
+ actual: currentSourceStateHash,
2395
+ expected: state.sourceStateHash,
2396
+ },
2397
+ }
2398
+ )
2399
+ );
2400
+ }
2401
+ const applyResult = applyVocabularyEvaluation(
2402
+ state.inputs.files,
2403
+ state.dryRunEvaluation
2404
+ );
2405
+ if (applyResult.isErr()) {
2406
+ return applyResult;
2407
+ }
2408
+ const appliedFiles = state.inputs.files.map((file) => ({
2409
+ ...file,
2410
+ source: readFileSync(file.absolutePath, 'utf8'),
2411
+ }));
2412
+ const postApplyEvaluation = buildRunVocabularyEvaluation({
2413
+ apply: true,
2414
+ effectivePlan: state.inputs.effectivePlan,
2415
+ files: appliedFiles,
2416
+ plan: state.params.plan,
2417
+ preserveInventory: state.params.preserveInventory,
2418
+ root: state.params.root,
2419
+ skipped: state.inputs.skipped,
2420
+ sourceKindForPath: state.params.sourceKindForPath,
2421
+ });
2422
+ return Result.ok(
2423
+ vocabularyReportFromEvaluation({
2424
+ applySummary: applyResult.value,
2425
+ collectedRoot: state.inputs.collectedRoot,
2426
+ dryRunEvaluation: state.dryRunEvaluation,
2427
+ effectivePlan: state.inputs.effectivePlan,
2428
+ entrySelection: state.params.includeEntries ?? 'actionable',
2429
+ files: appliedFiles,
2430
+ plan: state.params.plan,
2431
+ reportEvaluation: postApplyEvaluation,
2432
+ skipped: state.inputs.skipped,
2433
+ })
2434
+ );
2435
+ };
2436
+
2437
+ export const runVocabularyRegrade = (params: {
2438
+ readonly apply?: boolean;
2439
+ readonly includeEntries?: 'actionable' | 'all';
2440
+ readonly plan: VocabularyRegradePlan;
2441
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
2442
+ readonly root: string;
2443
+ readonly sourceFilter?: (path: string) => boolean;
2444
+ readonly sourceKindForPath?: (path: string) => VocabularySourceKind;
2445
+ }): Result<RegradeReport | null, InternalError | ValidationError> => {
2446
+ const planValidation = validateVocabularyPlan(params.plan);
2447
+ if (planValidation.isErr()) {
2448
+ return planValidation;
2449
+ }
2450
+ const inventoryValidation = validatePreserveInventory(
2451
+ params.preserveInventory
2452
+ );
2453
+ if (inventoryValidation.isErr()) {
2454
+ return inventoryValidation;
2455
+ }
2456
+
2457
+ const inputs = collectVocabularyRunInputs(params);
2458
+ if (inputs === null) {
2459
+ return Result.ok(null);
2460
+ }
2461
+ const { collectedRoot, effectivePlan, files, skipped } = inputs;
1945
2462
 
1946
2463
  const dryRunEffectiveEvaluation = buildRunVocabularyEvaluation({
1947
2464
  apply: false,
@@ -1951,6 +2468,7 @@ export const runVocabularyRegrade = (params: {
1951
2468
  preserveInventory: params.preserveInventory,
1952
2469
  root: params.root,
1953
2470
  skipped,
2471
+ sourceKindForPath: params.sourceKindForPath,
1954
2472
  });
1955
2473
  let reportEvaluation = dryRunEffectiveEvaluation;
1956
2474
  let applySummary: RegradeApplySummary | undefined;
@@ -1976,51 +2494,22 @@ export const runVocabularyRegrade = (params: {
1976
2494
  preserveInventory: params.preserveInventory,
1977
2495
  root: params.root,
1978
2496
  skipped,
2497
+ sourceKindForPath: params.sourceKindForPath,
1979
2498
  });
1980
2499
  }
1981
2500
 
1982
- const entrySelection = params.includeEntries ?? 'actionable';
1983
- const reportEntries = reportEvaluation.entries;
1984
- const actionableEntries = reportEntries.filter(
1985
- (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
1986
- );
1987
- const reportSkippedByReason = skippedByReason(reportEvaluation.skipped);
1988
- const report: RegradeReport = {
1989
- entries: entrySelection === 'all' ? reportEntries : actionableEntries,
1990
- matched: actionableEntries.length,
1991
- review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
1992
- .length,
1993
- rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
1994
- .length,
1995
- root: collected.root,
1996
- run:
1997
- applySummary === undefined
1998
- ? reportEvaluation.run
1999
- : vocabularyRunWithAppliedOccurrences(
2000
- reportEvaluation.run,
2001
- dryRunEffectiveEvaluation.run,
2002
- applySummary
2003
- ),
2004
- scan: buildRegradeScanSummary({
2005
- matchedPaths: actionableEntries.map((entry) => entry.path),
2006
- occurrencePaths: reportEvaluation.occurrences.map(
2007
- (occurrence) => occurrence.path
2008
- ),
2009
- scanned: reportEvaluation.scanned,
2010
- skipped: reportEvaluation.skipped.length,
2011
- skippedByReason: reportSkippedByReason,
2012
- }),
2013
- scanned: reportEvaluation.scanned,
2014
- selectedClassIds: [
2015
- params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
2016
- ],
2017
- skipped: reportEvaluation.skipped.length,
2018
- skipsByReason: reportSkippedByReason,
2019
- unknownClassIds: [],
2020
- };
2021
-
2022
2501
  return Result.ok(
2023
- applySummary === undefined ? report : withApplySummary(report, applySummary)
2502
+ vocabularyReportFromEvaluation({
2503
+ ...(applySummary === undefined ? {} : { applySummary }),
2504
+ collectedRoot,
2505
+ dryRunEvaluation: dryRunEffectiveEvaluation,
2506
+ effectivePlan,
2507
+ entrySelection: params.includeEntries ?? 'actionable',
2508
+ files,
2509
+ plan: params.plan,
2510
+ reportEvaluation,
2511
+ skipped,
2512
+ })
2024
2513
  );
2025
2514
  };
2026
2515
 
@@ -2170,6 +2659,10 @@ export const vocabularyRegradeRunOutput = z.object({
2170
2659
  scopeTier: z
2171
2660
  .enum(['in-scope', 'policy-classified'])
2172
2661
  .describe('Three-tier scope classification for this occurrence'),
2662
+ sourceKind: z
2663
+ .enum(['source-comment', 'tsdoc'])
2664
+ .optional()
2665
+ .describe('Exact source-comment construct for comment inventory'),
2173
2666
  start: z.number().describe('Source start offset'),
2174
2667
  verdict: z
2175
2668
  .enum(['applied', 'deferred', 'modified', 'skipped'])