@ontrails/regrade 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +606 -0
- package/package.json +40 -0
- package/src/downstream/ast-rewrite.ts +1031 -0
- package/src/downstream/collect.ts +240 -0
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/file-renames.ts +1677 -0
- package/src/downstream/package-source-artifact.ts +375 -0
- package/src/downstream/package-source-files.ts +195 -0
- package/src/downstream/package-source-manifest.ts +369 -0
- package/src/downstream/package-source.ts +237 -0
- package/src/downstream/report.ts +2085 -0
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary-registry.ts +195 -0
- package/src/downstream/vocabulary.ts +3094 -0
- package/src/history-receipt.ts +847 -0
- package/src/index.ts +170 -0
- package/src/literal-transform.ts +124 -0
|
@@ -0,0 +1,2085 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
Result,
|
|
4
|
+
ValidationError,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
includedByPathScope,
|
|
7
|
+
} from '@ontrails/core';
|
|
8
|
+
import type { ScanTargets } from '@ontrails/core';
|
|
9
|
+
import type {
|
|
10
|
+
GovernedVocabularyHistoryProvenance,
|
|
11
|
+
WardenDiagnostic,
|
|
12
|
+
WardenFixEdit,
|
|
13
|
+
WardenGuidance,
|
|
14
|
+
WardenRule,
|
|
15
|
+
} from '@ontrails/warden';
|
|
16
|
+
import {
|
|
17
|
+
getWardenRuleMetadata,
|
|
18
|
+
governedVocabularyHistoryProvenanceSchema,
|
|
19
|
+
isWardenSourceScanTarget,
|
|
20
|
+
loadProjectWardenRules,
|
|
21
|
+
wardenRules,
|
|
22
|
+
} from '@ontrails/warden';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_IGNORED_DIRECTORIES,
|
|
30
|
+
DEFAULT_SOURCE_EXTENSIONS,
|
|
31
|
+
collectDownstreamSources,
|
|
32
|
+
} from './collect.js';
|
|
33
|
+
import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
|
|
34
|
+
import {
|
|
35
|
+
buildRegradeScanSummary,
|
|
36
|
+
regradeScanSummaryOutput,
|
|
37
|
+
} from './scan-summary.js';
|
|
38
|
+
import type { RegradeScanSummary } from './scan-summary.js';
|
|
39
|
+
import type { VocabularyRegradeRun } from './vocabulary.js';
|
|
40
|
+
import { vocabularyRegradeRunOutput } from './vocabulary.js';
|
|
41
|
+
import type { RegradePackageSourceEvidence } from './package-source.js';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Regrade-class selection and coverage reporting (TRL-845).
|
|
45
|
+
*
|
|
46
|
+
* A regrade class is one named, contract-aware transform (for example a single
|
|
47
|
+
* vocabulary rename). Selection lets a run apply one class without executing
|
|
48
|
+
* every available transform, and {@link RegradeReport} captures coverage —
|
|
49
|
+
* what was scanned, matched, rewritten, routed to review, and skipped — with
|
|
50
|
+
* enough per-entry detail to debug why a file was omitted.
|
|
51
|
+
*
|
|
52
|
+
* The report logic is pure ({@link buildRegradeReport}); the filesystem walk
|
|
53
|
+
* and file reads live in {@link runRegrade} and the wrapping trail. This keeps
|
|
54
|
+
* coverage semantics testable without disk and consistent with the downstream
|
|
55
|
+
* collection substrate (TRL-844).
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Outcome a regrade class produces for a single source file. `skipped` means
|
|
60
|
+
* the class declined to inspect the file (for example, a scan-target filter
|
|
61
|
+
* excluded it) and it must not count as a scanned/clean no-op.
|
|
62
|
+
*/
|
|
63
|
+
export type RegradeOutcomeKind =
|
|
64
|
+
| 'needs-review'
|
|
65
|
+
| 'no-op'
|
|
66
|
+
| 'rewrite'
|
|
67
|
+
| 'skipped';
|
|
68
|
+
|
|
69
|
+
/** Result of applying one regrade class to one source string. */
|
|
70
|
+
export interface RegradeClassResult {
|
|
71
|
+
readonly kind: RegradeOutcomeKind;
|
|
72
|
+
/** Rewritten source, present only when `kind` is `rewrite`. */
|
|
73
|
+
readonly nextSource?: string;
|
|
74
|
+
/** Human-readable notes explaining the outcome. */
|
|
75
|
+
readonly notes: readonly string[];
|
|
76
|
+
/** Machine-readable reason for review outcomes. */
|
|
77
|
+
readonly reason?: string;
|
|
78
|
+
/** Structured details for review outcomes. */
|
|
79
|
+
readonly reviewDetails?: readonly RegradeReviewDetail[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Source-file context passed to Regrade classes. */
|
|
83
|
+
export interface RegradeClassContext {
|
|
84
|
+
/** Root-relative POSIX path. */
|
|
85
|
+
readonly path: string;
|
|
86
|
+
/** Absolute path on disk, when the caller has one. */
|
|
87
|
+
readonly absolutePath?: string;
|
|
88
|
+
/** Nearest owning package facts, when filesystem collection found a manifest. */
|
|
89
|
+
readonly package?: {
|
|
90
|
+
readonly dependencies: readonly string[];
|
|
91
|
+
/** Runtime-visible dependency declarations (dependencies, optional, peer). */
|
|
92
|
+
readonly runtimeDependencies?: readonly string[];
|
|
93
|
+
readonly manifestState?: 'invalid' | 'valid';
|
|
94
|
+
readonly name?: string;
|
|
95
|
+
/** Root-relative POSIX manifest path. */
|
|
96
|
+
readonly path: string;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Files a regrade class knows how to inspect. */
|
|
101
|
+
export type RegradeScanTargets = ScanTargets & {
|
|
102
|
+
/**
|
|
103
|
+
* @deprecated Use collection-level `exclude` globs. Preserved so existing
|
|
104
|
+
* Regrade classes can explicitly opt into directories the default collector
|
|
105
|
+
* prunes, such as `dist`, while migrating to PathScope.
|
|
106
|
+
*/
|
|
107
|
+
readonly ignoredDirectories?: readonly string[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** One named, contract-aware transform. */
|
|
111
|
+
export interface RegradeClass {
|
|
112
|
+
/** Stable identifier, e.g. `term-rewrite:signal->ping`. */
|
|
113
|
+
readonly id: string;
|
|
114
|
+
/** What the class does, for report and guide surfaces. */
|
|
115
|
+
readonly describe: string;
|
|
116
|
+
/** Apply the class to a source string. Must be pure and never throw. */
|
|
117
|
+
readonly apply: (
|
|
118
|
+
source: string,
|
|
119
|
+
context?: RegradeClassContext
|
|
120
|
+
) => RegradeClassResult;
|
|
121
|
+
/** Scan targets this class knows how to inspect. */
|
|
122
|
+
readonly scanTargets?: RegradeScanTargets;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RegradeWardenClassSet {
|
|
126
|
+
/** Built-in and project-local Warden term-rewrite classes. */
|
|
127
|
+
readonly classes: readonly RegradeClass[];
|
|
128
|
+
/** Diagnostics raised while loading project-local rules. */
|
|
129
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Which regrade classes a run should execute. */
|
|
133
|
+
export interface RegradeSelection {
|
|
134
|
+
/** Class ids to run. Omit to run every provided class. */
|
|
135
|
+
readonly classIds?: readonly string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Which report entries should be returned. Counts always cover the full run. */
|
|
139
|
+
export type RegradeReportEntrySelection = 'actionable' | 'all';
|
|
140
|
+
|
|
141
|
+
/** Optional write summary for an apply-mode regrade run. */
|
|
142
|
+
export interface RegradeApplySummary {
|
|
143
|
+
/** Safe rewrite outcomes written to disk. */
|
|
144
|
+
readonly applied: number;
|
|
145
|
+
/** Distinct files changed on disk. */
|
|
146
|
+
readonly filesChanged: number;
|
|
147
|
+
/** Rewrite candidates intentionally not written. */
|
|
148
|
+
readonly skipped: number;
|
|
149
|
+
/** Files still requiring review. */
|
|
150
|
+
readonly review: number;
|
|
151
|
+
/** Unknown selected class ids; apply mode writes nothing when non-zero. */
|
|
152
|
+
readonly unknown: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Caller-authored identity for one prepared Regrade evaluation.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* const identity: PreparedRegradeRunIdentity = {
|
|
161
|
+
* lockStateHash: 'lock-sha256',
|
|
162
|
+
* planContentHash: 'plan-sha256',
|
|
163
|
+
* policyHash: 'policy-sha256',
|
|
164
|
+
* scopeHash: 'scope-sha256',
|
|
165
|
+
* toolVersion: '1.0.0',
|
|
166
|
+
* };
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export interface PreparedRegradeRunIdentity {
|
|
170
|
+
readonly lockStateHash: string;
|
|
171
|
+
readonly planContentHash: string;
|
|
172
|
+
readonly policyHash: string;
|
|
173
|
+
readonly scopeHash: string;
|
|
174
|
+
readonly toolVersion: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* In-memory class/symbol Regrade evaluation ready for freshness-checked apply.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* const prepared = prepareRegradeRun({ classes, identity, root });
|
|
183
|
+
* if (prepared.isOk() && prepared.value !== null) {
|
|
184
|
+
* console.log(prepared.value.sourceStateHash);
|
|
185
|
+
* }
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export interface PreparedRegradeRun {
|
|
189
|
+
readonly identity: PreparedRegradeRunIdentity;
|
|
190
|
+
readonly report: RegradeReport;
|
|
191
|
+
readonly sourceStateHash: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Source location for a review-required match. */
|
|
195
|
+
export interface RegradeReviewSpan {
|
|
196
|
+
readonly column: number;
|
|
197
|
+
readonly end: number;
|
|
198
|
+
readonly line: number;
|
|
199
|
+
readonly start: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Verdict state for a review detail.
|
|
204
|
+
*
|
|
205
|
+
* - `unresolved`: the class could not complete occurrence judgment; a human or
|
|
206
|
+
* agent decision is still needed.
|
|
207
|
+
* - `preserve`: a completed verdict to keep the occurrence as-is.
|
|
208
|
+
* - `rewrite`: a completed verdict that a rewrite is intended but this run
|
|
209
|
+
* could not apply it (for example invalid or missing edits).
|
|
210
|
+
*/
|
|
211
|
+
export type RegradeReviewJudgment = 'preserve' | 'rewrite' | 'unresolved';
|
|
212
|
+
|
|
213
|
+
/** Structured detail explaining why a source match needs review. */
|
|
214
|
+
export interface RegradeReviewDetail {
|
|
215
|
+
/** Concrete replacement the class would apply if the occurrence were judged safe. */
|
|
216
|
+
readonly candidateReplacement?: string;
|
|
217
|
+
/** Class that produced the review detail, injected by report building. */
|
|
218
|
+
readonly classId?: string;
|
|
219
|
+
/** Exact source-line context containing the occurrence under review. */
|
|
220
|
+
readonly context?: string;
|
|
221
|
+
/** Expected target shape when the class can describe one. */
|
|
222
|
+
readonly expectedTarget?: string;
|
|
223
|
+
/** Fixture or example reference that illustrates the expected migration. */
|
|
224
|
+
readonly fixture?: string;
|
|
225
|
+
/** Whether occurrence judgment is unresolved or a preserve/rewrite verdict completed. */
|
|
226
|
+
readonly judgment?: RegradeReviewJudgment;
|
|
227
|
+
/** Exact matched source text for the occurrence under review. */
|
|
228
|
+
readonly matchedForm?: string;
|
|
229
|
+
/** AST node kind or source construct kind. */
|
|
230
|
+
readonly nodeKind?: string;
|
|
231
|
+
/** Cautions explaining why a blind rewrite of this occurrence is unsafe. */
|
|
232
|
+
readonly preserveCautions?: readonly string[];
|
|
233
|
+
/** Machine-readable reason for review. */
|
|
234
|
+
readonly reason: string;
|
|
235
|
+
/** Machine-readable provenance tags for the producing rule or class. */
|
|
236
|
+
readonly signals?: readonly string[];
|
|
237
|
+
/** Source span and line/column for the review-required match. */
|
|
238
|
+
readonly span?: RegradeReviewSpan;
|
|
239
|
+
/** Suggested validation command after the review is resolved. */
|
|
240
|
+
readonly suggestedValidation?: string;
|
|
241
|
+
/** Symbol or term that triggered review. */
|
|
242
|
+
readonly symbol?: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build a whole-word term-rewrite class.
|
|
247
|
+
*
|
|
248
|
+
* Whole-word occurrences of `from` are rewritten to `to`. When `from` appears
|
|
249
|
+
* only as part of a larger identifier (an ambiguous partial match), the class
|
|
250
|
+
* routes the file to review instead of rewriting it — the canonical
|
|
251
|
+
* "rename `signal` but do not touch `signalHandler`" case. This standalone
|
|
252
|
+
* class anticipates TRL-832/836, where the mappings become Warden-owned.
|
|
253
|
+
*
|
|
254
|
+
* Matching is raw-text and lexer-unaware: a whole-word `from` inside a comment
|
|
255
|
+
* or string literal counts exactly like a code reference (it is rewritten, and
|
|
256
|
+
* a partial occurrence there still routes the file to review). For a vocabulary
|
|
257
|
+
* migration this is usually desirable — comments and docs should track the
|
|
258
|
+
* rename too — but it means callers cannot assume comment/string occurrences
|
|
259
|
+
* are skipped. Lexer/AST-aware exclusion is deferred to the Warden-owned
|
|
260
|
+
* term-rewrite metadata work (TRL-832/836).
|
|
261
|
+
*/
|
|
262
|
+
export const createTermRewriteClass = (options: {
|
|
263
|
+
readonly from: string;
|
|
264
|
+
readonly to: string;
|
|
265
|
+
readonly id?: string;
|
|
266
|
+
readonly describe?: string;
|
|
267
|
+
}): RegradeClass => {
|
|
268
|
+
const { from, to } = options;
|
|
269
|
+
const wholeWord = new RegExp(`\\b${escapeRegExp(from)}\\b`, 'g');
|
|
270
|
+
return {
|
|
271
|
+
apply: (source: string): RegradeClassResult => {
|
|
272
|
+
const matches = source.match(wholeWord);
|
|
273
|
+
const hasAmbiguousPartial = source.replace(wholeWord, '').includes(from);
|
|
274
|
+
if (hasAmbiguousPartial) {
|
|
275
|
+
return {
|
|
276
|
+
kind: 'needs-review',
|
|
277
|
+
notes: [
|
|
278
|
+
`Found "${from}" inside larger identifiers; routed to review.`,
|
|
279
|
+
],
|
|
280
|
+
reason: 'ambiguous-match',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (matches && matches.length > 0) {
|
|
284
|
+
return {
|
|
285
|
+
kind: 'rewrite',
|
|
286
|
+
nextSource: source.replace(wholeWord, to),
|
|
287
|
+
notes: [`Rewrote ${matches.length} whole-word "${from}" -> "${to}".`],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { kind: 'no-op', notes: [`No "${from}" occurrences found.`] };
|
|
291
|
+
},
|
|
292
|
+
describe: options.describe ?? `Rewrite "${from}" to "${to}".`,
|
|
293
|
+
id: options.id ?? `term-rewrite:${from}->${to}`,
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const TERM_REWRITE_FIX_CLASS = 'term-rewrite';
|
|
298
|
+
|
|
299
|
+
type WardenEditApplication =
|
|
300
|
+
| { readonly ok: true; readonly nextSource: string }
|
|
301
|
+
| { readonly ok: false; readonly reason: string };
|
|
302
|
+
|
|
303
|
+
const regradeScanTargetsFromWardenFix = (
|
|
304
|
+
scanTargets: NonNullable<
|
|
305
|
+
NonNullable<ReturnType<typeof getWardenRuleMetadata>>['fix']
|
|
306
|
+
>['scanTargets']
|
|
307
|
+
): RegradeScanTargets | undefined => {
|
|
308
|
+
if (scanTargets === undefined) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
...(scanTargets.exclude === undefined
|
|
313
|
+
? {}
|
|
314
|
+
: { exclude: scanTargets.exclude }),
|
|
315
|
+
...(scanTargets.extensions === undefined
|
|
316
|
+
? {}
|
|
317
|
+
: { extensions: scanTargets.extensions }),
|
|
318
|
+
...(scanTargets.ignoredDirectories === undefined
|
|
319
|
+
? {}
|
|
320
|
+
: { ignoredDirectories: scanTargets.ignoredDirectories }),
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const diagnosticNote = (diagnostic: WardenDiagnostic): string => {
|
|
325
|
+
const reason = diagnostic.fix?.reason ?? diagnostic.message;
|
|
326
|
+
return `${diagnostic.rule}:${diagnostic.line}: ${reason}`;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const firstQuotedValue = (value: string | undefined): string | undefined => {
|
|
330
|
+
if (value === undefined) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
const match = /['"]([^'"]+)['"]/.exec(value);
|
|
334
|
+
return match?.[1];
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const reviewSpanForOffsets = (
|
|
338
|
+
source: string,
|
|
339
|
+
start: number,
|
|
340
|
+
end: number
|
|
341
|
+
): RegradeReviewSpan => {
|
|
342
|
+
let line = 1;
|
|
343
|
+
let column = 1;
|
|
344
|
+
for (let index = 0; index < start; index += 1) {
|
|
345
|
+
if (source.codePointAt(index) === 10) {
|
|
346
|
+
line += 1;
|
|
347
|
+
column = 1;
|
|
348
|
+
} else {
|
|
349
|
+
column += 1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { column, end, line, start };
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const spanForSymbolOnDiagnosticLine = (
|
|
356
|
+
source: string,
|
|
357
|
+
line: number,
|
|
358
|
+
symbol: string
|
|
359
|
+
): RegradeReviewSpan | undefined => {
|
|
360
|
+
if (line < 1) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
let lineStart = 0;
|
|
364
|
+
for (let currentLine = 1; currentLine < line; currentLine += 1) {
|
|
365
|
+
const nextLineStart = source.indexOf('\n', lineStart);
|
|
366
|
+
if (nextLineStart === -1) {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
lineStart = nextLineStart + 1;
|
|
370
|
+
}
|
|
371
|
+
const nextLineStart = source.indexOf('\n', lineStart);
|
|
372
|
+
const lineEnd = nextLineStart === -1 ? source.length : nextLineStart;
|
|
373
|
+
const symbolIndex = source.indexOf(symbol, lineStart);
|
|
374
|
+
if (symbolIndex === -1 || symbolIndex >= lineEnd) {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
const nextSymbolIndex = source.indexOf(symbol, symbolIndex + symbol.length);
|
|
378
|
+
if (nextSymbolIndex !== -1 && nextSymbolIndex < lineEnd) {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
return reviewSpanForOffsets(source, symbolIndex, symbolIndex + symbol.length);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const diagnosticSpan = (
|
|
385
|
+
source: string,
|
|
386
|
+
diagnostic: WardenDiagnostic,
|
|
387
|
+
symbol: string | undefined
|
|
388
|
+
): RegradeReviewSpan | undefined => {
|
|
389
|
+
const [edit] = diagnostic.fix?.edits ?? [];
|
|
390
|
+
if (edit !== undefined) {
|
|
391
|
+
return reviewSpanForOffsets(source, edit.start, edit.end);
|
|
392
|
+
}
|
|
393
|
+
if (symbol === undefined) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
return spanForSymbolOnDiagnosticLine(source, diagnostic.line, symbol);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const diagnosticCandidateReplacement = (
|
|
400
|
+
diagnostic: WardenDiagnostic
|
|
401
|
+
): string | undefined => {
|
|
402
|
+
const replacements = new Set(
|
|
403
|
+
(diagnostic.fix?.edits ?? []).map((edit) => edit.replacement)
|
|
404
|
+
);
|
|
405
|
+
if (replacements.size !== 1) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
const [replacement] = replacements;
|
|
409
|
+
return replacement;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
|
|
413
|
+
const replacement = diagnosticCandidateReplacement(diagnostic);
|
|
414
|
+
return replacement === undefined
|
|
415
|
+
? undefined
|
|
416
|
+
: `Replace with "${replacement}".`;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const isValidEditSpan = (
|
|
420
|
+
source: string,
|
|
421
|
+
edit: WardenFixEdit | undefined
|
|
422
|
+
): edit is WardenFixEdit =>
|
|
423
|
+
edit !== undefined &&
|
|
424
|
+
Number.isInteger(edit.start) &&
|
|
425
|
+
Number.isInteger(edit.end) &&
|
|
426
|
+
edit.start >= 0 &&
|
|
427
|
+
edit.end >= edit.start &&
|
|
428
|
+
edit.end <= source.length;
|
|
429
|
+
|
|
430
|
+
const diagnosticMatchedForm = (
|
|
431
|
+
source: string,
|
|
432
|
+
diagnostic: WardenDiagnostic,
|
|
433
|
+
symbol: string | undefined
|
|
434
|
+
): string | undefined => {
|
|
435
|
+
const [edit] = diagnostic.fix?.edits ?? [];
|
|
436
|
+
if (isValidEditSpan(source, edit)) {
|
|
437
|
+
return source.slice(edit.start, edit.end);
|
|
438
|
+
}
|
|
439
|
+
return symbol;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const diagnosticSignals = (diagnostic: WardenDiagnostic): readonly string[] => [
|
|
443
|
+
`warden:${diagnostic.rule}`,
|
|
444
|
+
...(diagnostic.code === undefined
|
|
445
|
+
? []
|
|
446
|
+
: [`${diagnostic.rule}:${diagnostic.code}`]),
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
interface WardenReviewMappingOptions {
|
|
450
|
+
/** Verdict state for this review path. */
|
|
451
|
+
readonly judgment: RegradeReviewJudgment;
|
|
452
|
+
/** Machine-readable review reason. */
|
|
453
|
+
readonly reason: string;
|
|
454
|
+
/** Rule-level guidance used when a finding carries none of its own. */
|
|
455
|
+
readonly ruleGuidance?: WardenGuidance;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const reviewDetailFromDiagnostic = (
|
|
459
|
+
source: string,
|
|
460
|
+
diagnostic: WardenDiagnostic,
|
|
461
|
+
options: WardenReviewMappingOptions
|
|
462
|
+
): RegradeReviewDetail => {
|
|
463
|
+
const symbol =
|
|
464
|
+
firstQuotedValue(diagnostic.fix?.reason) ??
|
|
465
|
+
firstQuotedValue(diagnostic.message);
|
|
466
|
+
const span = diagnosticSpan(source, diagnostic, symbol);
|
|
467
|
+
const target = expectedTarget(diagnostic);
|
|
468
|
+
const replacement = diagnosticCandidateReplacement(diagnostic);
|
|
469
|
+
const matchedForm = diagnosticMatchedForm(source, diagnostic, symbol);
|
|
470
|
+
const guidance = diagnostic.guidance ?? options.ruleGuidance;
|
|
471
|
+
const preserveCautions =
|
|
472
|
+
guidance === undefined
|
|
473
|
+
? undefined
|
|
474
|
+
: [guidance.summary, ...(guidance.steps ?? [])];
|
|
475
|
+
const suggestedValidation = guidance?.commands?.[0];
|
|
476
|
+
return {
|
|
477
|
+
...(replacement === undefined ? {} : { candidateReplacement: replacement }),
|
|
478
|
+
...(target === undefined ? {} : { expectedTarget: target }),
|
|
479
|
+
...(diagnostic.fix?.fixture === undefined
|
|
480
|
+
? {}
|
|
481
|
+
: { fixture: diagnostic.fix.fixture }),
|
|
482
|
+
judgment: options.judgment,
|
|
483
|
+
...(matchedForm === undefined ? {} : { matchedForm }),
|
|
484
|
+
...(preserveCautions === undefined ? {} : { preserveCautions }),
|
|
485
|
+
reason: options.reason,
|
|
486
|
+
signals: diagnosticSignals(diagnostic),
|
|
487
|
+
...(span === undefined ? {} : { span }),
|
|
488
|
+
...(suggestedValidation === undefined ? {} : { suggestedValidation }),
|
|
489
|
+
...(symbol === undefined ? {} : { symbol }),
|
|
490
|
+
} satisfies RegradeReviewDetail;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const reviewDetailsFromDiagnostics = (
|
|
494
|
+
source: string,
|
|
495
|
+
diagnostics: readonly WardenDiagnostic[],
|
|
496
|
+
options: WardenReviewMappingOptions
|
|
497
|
+
): readonly RegradeReviewDetail[] | undefined => {
|
|
498
|
+
const details = diagnostics.map((diagnostic) =>
|
|
499
|
+
reviewDetailFromDiagnostic(source, diagnostic, options)
|
|
500
|
+
);
|
|
501
|
+
return details.length === 0 ? undefined : details;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const wardenFilePath = (context: RegradeClassContext | undefined): string =>
|
|
505
|
+
context?.absolutePath ?? context?.path ?? '<regrade-source>';
|
|
506
|
+
|
|
507
|
+
const wardenScanPath = (context: RegradeClassContext | undefined): string =>
|
|
508
|
+
context?.path ?? context?.absolutePath ?? '<regrade-source>';
|
|
509
|
+
|
|
510
|
+
const applyWardenEdits = (
|
|
511
|
+
source: string,
|
|
512
|
+
edits: readonly WardenFixEdit[]
|
|
513
|
+
): WardenEditApplication => {
|
|
514
|
+
const ordered = [...edits].toSorted((a, b) => a.start - b.start);
|
|
515
|
+
let previousEnd = 0;
|
|
516
|
+
for (const edit of ordered) {
|
|
517
|
+
if (
|
|
518
|
+
!Number.isInteger(edit.start) ||
|
|
519
|
+
!Number.isInteger(edit.end) ||
|
|
520
|
+
edit.start < 0 ||
|
|
521
|
+
edit.end < edit.start ||
|
|
522
|
+
edit.end > source.length
|
|
523
|
+
) {
|
|
524
|
+
return { ok: false, reason: 'invalid-edit-span' };
|
|
525
|
+
}
|
|
526
|
+
if (edit.start < previousEnd) {
|
|
527
|
+
return { ok: false, reason: 'overlapping-edit-spans' };
|
|
528
|
+
}
|
|
529
|
+
previousEnd = edit.end;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let nextSource = source;
|
|
533
|
+
for (const edit of ordered.toReversed()) {
|
|
534
|
+
nextSource =
|
|
535
|
+
nextSource.slice(0, edit.start) +
|
|
536
|
+
edit.replacement +
|
|
537
|
+
nextSource.slice(edit.end);
|
|
538
|
+
}
|
|
539
|
+
return { nextSource, ok: true };
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export const createWardenTermRewriteClass = (
|
|
543
|
+
rule: WardenRule
|
|
544
|
+
): RegradeClass | null => {
|
|
545
|
+
const metadata = getWardenRuleMetadata(rule);
|
|
546
|
+
if (metadata?.fix?.class !== TERM_REWRITE_FIX_CLASS) {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
const scanTargets = regradeScanTargetsFromWardenFix(metadata.fix.scanTargets);
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
apply: (
|
|
553
|
+
source: string,
|
|
554
|
+
context?: RegradeClassContext
|
|
555
|
+
): RegradeClassResult => {
|
|
556
|
+
if (!isWardenSourceScanTarget(wardenScanPath(context))) {
|
|
557
|
+
return {
|
|
558
|
+
kind: 'skipped',
|
|
559
|
+
notes: ['Skipped by Warden source scan-target filtering.'],
|
|
560
|
+
reason: 'warden-scan-target-filtered',
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const diagnostics = rule
|
|
565
|
+
.check(source, wardenFilePath(context))
|
|
566
|
+
.filter(
|
|
567
|
+
(diagnostic) => diagnostic.fix?.class === TERM_REWRITE_FIX_CLASS
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
if (diagnostics.length === 0) {
|
|
571
|
+
return {
|
|
572
|
+
kind: 'no-op',
|
|
573
|
+
notes: [`No Warden ${TERM_REWRITE_FIX_CLASS} diagnostics found.`],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const reviewDiagnostics = diagnostics.filter(
|
|
578
|
+
(diagnostic) => diagnostic.fix?.safety !== 'safe'
|
|
579
|
+
);
|
|
580
|
+
if (reviewDiagnostics.length > 0) {
|
|
581
|
+
// The rule flagged the occurrence but marked it review: occurrence
|
|
582
|
+
// judgment is unresolved and needs a human or agent decision.
|
|
583
|
+
const reviewDetails = reviewDetailsFromDiagnostics(
|
|
584
|
+
source,
|
|
585
|
+
reviewDiagnostics,
|
|
586
|
+
{
|
|
587
|
+
judgment: 'unresolved',
|
|
588
|
+
reason: 'warden-review-required',
|
|
589
|
+
...(metadata.guidance === undefined
|
|
590
|
+
? {}
|
|
591
|
+
: { ruleGuidance: metadata.guidance }),
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
return {
|
|
595
|
+
kind: 'needs-review',
|
|
596
|
+
notes: reviewDiagnostics.map(diagnosticNote),
|
|
597
|
+
reason: 'warden-review-required',
|
|
598
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const diagnosticsMissingEdits = diagnostics.filter(
|
|
603
|
+
(diagnostic) => (diagnostic.fix?.edits?.length ?? 0) === 0
|
|
604
|
+
);
|
|
605
|
+
if (diagnosticsMissingEdits.length > 0) {
|
|
606
|
+
// A safe fix without concrete edits cannot complete occurrence
|
|
607
|
+
// judgment on its own, so the verdict stays unresolved.
|
|
608
|
+
const reviewDetails = reviewDetailsFromDiagnostics(
|
|
609
|
+
source,
|
|
610
|
+
diagnosticsMissingEdits,
|
|
611
|
+
{
|
|
612
|
+
judgment: 'unresolved',
|
|
613
|
+
reason: 'warden-fix-missing-edits',
|
|
614
|
+
...(metadata.guidance === undefined
|
|
615
|
+
? {}
|
|
616
|
+
: { ruleGuidance: metadata.guidance }),
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
return {
|
|
620
|
+
kind: 'needs-review',
|
|
621
|
+
notes: diagnostics.map(diagnosticNote),
|
|
622
|
+
reason: 'warden-fix-missing-edits',
|
|
623
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const edits = diagnostics.flatMap(
|
|
628
|
+
(diagnostic) => diagnostic.fix?.edits ?? []
|
|
629
|
+
);
|
|
630
|
+
const application = applyWardenEdits(source, edits);
|
|
631
|
+
if (!application.ok) {
|
|
632
|
+
// The rule completed judgment — it authored concrete edits — but this
|
|
633
|
+
// run could not apply them, so the verdict is a rewrite left undone.
|
|
634
|
+
const reviewDetails = reviewDetailsFromDiagnostics(
|
|
635
|
+
source,
|
|
636
|
+
diagnostics,
|
|
637
|
+
{
|
|
638
|
+
judgment: 'rewrite',
|
|
639
|
+
reason: 'warden-fix-invalid',
|
|
640
|
+
...(metadata.guidance === undefined
|
|
641
|
+
? {}
|
|
642
|
+
: { ruleGuidance: metadata.guidance }),
|
|
643
|
+
}
|
|
644
|
+
);
|
|
645
|
+
return {
|
|
646
|
+
kind: 'needs-review',
|
|
647
|
+
notes: [
|
|
648
|
+
...diagnostics.map(diagnosticNote),
|
|
649
|
+
`Warden fix edits could not be applied: ${application.reason}.`,
|
|
650
|
+
],
|
|
651
|
+
reason: 'warden-fix-invalid',
|
|
652
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
kind: 'rewrite',
|
|
658
|
+
nextSource: application.nextSource,
|
|
659
|
+
notes: diagnostics.map(diagnosticNote),
|
|
660
|
+
};
|
|
661
|
+
},
|
|
662
|
+
describe: `${rule.description} (${metadata.fix.safety} ${metadata.fix.class})`,
|
|
663
|
+
id: `${metadata.fix.class}:${rule.name}`,
|
|
664
|
+
...(scanTargets === undefined ? {} : { scanTargets }),
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Resolve the selected classes, preserving the order of `classIds` when given.
|
|
670
|
+
* Unknown selected ids are returned so callers can report them.
|
|
671
|
+
*/
|
|
672
|
+
export const selectRegradeClasses = (
|
|
673
|
+
classes: readonly RegradeClass[],
|
|
674
|
+
selection: RegradeSelection = {}
|
|
675
|
+
): {
|
|
676
|
+
readonly selected: readonly RegradeClass[];
|
|
677
|
+
readonly unknownClassIds: readonly string[];
|
|
678
|
+
} => {
|
|
679
|
+
if (selection.classIds === undefined) {
|
|
680
|
+
return { selected: classes, unknownClassIds: [] };
|
|
681
|
+
}
|
|
682
|
+
const byId = new Map(classes.map((cls) => [cls.id, cls]));
|
|
683
|
+
const selected: RegradeClass[] = [];
|
|
684
|
+
const unknownClassIds: string[] = [];
|
|
685
|
+
for (const id of selection.classIds) {
|
|
686
|
+
const cls = byId.get(id);
|
|
687
|
+
if (cls === undefined) {
|
|
688
|
+
unknownClassIds.push(id);
|
|
689
|
+
} else {
|
|
690
|
+
selected.push(cls);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { selected, unknownClassIds };
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const uniqueSorted = (values: readonly string[]): readonly string[] =>
|
|
697
|
+
[...new Set(values)].toSorted((a, b) => a.localeCompare(b));
|
|
698
|
+
|
|
699
|
+
const intersectValues = (
|
|
700
|
+
left: readonly string[],
|
|
701
|
+
right: readonly string[]
|
|
702
|
+
): readonly string[] => left.filter((value) => right.includes(value));
|
|
703
|
+
|
|
704
|
+
const deriveClassIgnoredDirectories = (
|
|
705
|
+
classes: readonly RegradeClass[]
|
|
706
|
+
): readonly string[] | undefined => {
|
|
707
|
+
const explicitTargets = classes
|
|
708
|
+
.map((cls) => cls.scanTargets?.ignoredDirectories)
|
|
709
|
+
.filter((value): value is readonly string[] => value !== undefined);
|
|
710
|
+
if (explicitTargets.length === 0) {
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let common = explicitTargets[0] ?? [];
|
|
715
|
+
for (const target of explicitTargets.slice(1)) {
|
|
716
|
+
common = intersectValues(common, target);
|
|
717
|
+
}
|
|
718
|
+
return uniqueSorted(common);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const deriveCollectionOptions = (
|
|
722
|
+
classes: readonly RegradeClass[],
|
|
723
|
+
collection: DownstreamCollectionOptions | undefined
|
|
724
|
+
): DownstreamCollectionOptions => {
|
|
725
|
+
const allExtensions = classes.some(
|
|
726
|
+
(cls) => cls.scanTargets?.extensions?.length === 0
|
|
727
|
+
);
|
|
728
|
+
const targetExtensions = allExtensions
|
|
729
|
+
? []
|
|
730
|
+
: uniqueSorted(
|
|
731
|
+
classes.length === 0
|
|
732
|
+
? DEFAULT_SOURCE_EXTENSIONS
|
|
733
|
+
: classes.flatMap(
|
|
734
|
+
(cls) => cls.scanTargets?.extensions ?? DEFAULT_SOURCE_EXTENSIONS
|
|
735
|
+
)
|
|
736
|
+
);
|
|
737
|
+
return {
|
|
738
|
+
...(collection?.exclude === undefined
|
|
739
|
+
? {}
|
|
740
|
+
: { exclude: collection.exclude }),
|
|
741
|
+
...(collection?.include === undefined
|
|
742
|
+
? {}
|
|
743
|
+
: { include: collection.include }),
|
|
744
|
+
extensions: collection?.extensions ?? targetExtensions,
|
|
745
|
+
ignoredDirectories:
|
|
746
|
+
collection?.ignoredDirectories ??
|
|
747
|
+
deriveClassIgnoredDirectories(classes) ??
|
|
748
|
+
DEFAULT_IGNORED_DIRECTORIES,
|
|
749
|
+
};
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
/** Per-entry detail describing what happened to one path. */
|
|
753
|
+
export interface RegradeReportEntry {
|
|
754
|
+
/** Root-relative POSIX path. */
|
|
755
|
+
readonly path: string;
|
|
756
|
+
/** What happened to the entry. */
|
|
757
|
+
readonly outcome: 'needs-review' | 'no-op' | 'rewrite' | 'skip';
|
|
758
|
+
/** Class that produced a rewrite or review outcome. */
|
|
759
|
+
readonly classId?: string;
|
|
760
|
+
/** Reason for a skip or review outcome. */
|
|
761
|
+
readonly reason?: string;
|
|
762
|
+
/** Notes carried from the producing class. */
|
|
763
|
+
readonly notes?: readonly string[];
|
|
764
|
+
/** Structured review details carried from the producing class. */
|
|
765
|
+
readonly reviewDetails?: readonly RegradeReviewDetail[];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Coverage report for a regrade run. */
|
|
769
|
+
export interface RegradeReport {
|
|
770
|
+
/** Root the run scanned. */
|
|
771
|
+
readonly root: string;
|
|
772
|
+
/** Class ids that were executed. */
|
|
773
|
+
readonly selectedClassIds: readonly string[];
|
|
774
|
+
/** Selected class ids that did not resolve to a known class. */
|
|
775
|
+
readonly unknownClassIds: readonly string[];
|
|
776
|
+
/** Source files inspected. */
|
|
777
|
+
readonly scanned: number;
|
|
778
|
+
/**
|
|
779
|
+
* Root-relative source paths used to deduplicate composed scans.
|
|
780
|
+
*
|
|
781
|
+
* @internal
|
|
782
|
+
*/
|
|
783
|
+
readonly scannedPaths?: readonly string[];
|
|
784
|
+
/** Files where a selected class produced a rewrite or review outcome. */
|
|
785
|
+
readonly matched: number;
|
|
786
|
+
/** Files with a rewrite outcome. */
|
|
787
|
+
readonly rewritten: number;
|
|
788
|
+
/** Files routed to review. */
|
|
789
|
+
readonly review: number;
|
|
790
|
+
/** Entries skipped (collection skips plus any run-level skips). */
|
|
791
|
+
readonly skipped: number;
|
|
792
|
+
/** Skipped entries grouped by reason. */
|
|
793
|
+
readonly skipsByReason: Readonly<Record<string, number>>;
|
|
794
|
+
/** Agent-facing inventory summary for the scan. */
|
|
795
|
+
readonly scan: RegradeScanSummary;
|
|
796
|
+
/** Per-entry detail, sorted by path. */
|
|
797
|
+
readonly entries: readonly RegradeReportEntry[];
|
|
798
|
+
/** Apply-mode summary; absent for dry-run report-only calls. */
|
|
799
|
+
readonly apply?: RegradeApplySummary;
|
|
800
|
+
/** Verified source evidence for the selected downstream Trails package. */
|
|
801
|
+
readonly packageSource?: RegradePackageSourceEvidence;
|
|
802
|
+
/** Vocabulary regrade run: plan, ledger, and completion report. */
|
|
803
|
+
readonly run?: VocabularyRegradeRun;
|
|
804
|
+
/** Saved active Regrade plan evidence for vocabulary regrades. */
|
|
805
|
+
readonly plan?: {
|
|
806
|
+
readonly expansionPending?: number;
|
|
807
|
+
readonly path: string;
|
|
808
|
+
readonly schemaVersion: number;
|
|
809
|
+
readonly status: 'active' | 'stale';
|
|
810
|
+
};
|
|
811
|
+
/** Saved applied Regrade history evidence for vocabulary regrades. */
|
|
812
|
+
readonly history?: {
|
|
813
|
+
readonly id?: string;
|
|
814
|
+
readonly path: string;
|
|
815
|
+
readonly provenance?: GovernedVocabularyHistoryProvenance;
|
|
816
|
+
readonly schemaVersion: number;
|
|
817
|
+
readonly status: 'applied' | 'checked' | 'replay';
|
|
818
|
+
};
|
|
819
|
+
/**
|
|
820
|
+
* @deprecated Persisted transition record evidence for vocabulary regrades.
|
|
821
|
+
* Use `plan` and `history` summaries in public surfaces.
|
|
822
|
+
*/
|
|
823
|
+
readonly record?: {
|
|
824
|
+
readonly path: string;
|
|
825
|
+
readonly schemaVersion: number;
|
|
826
|
+
readonly status: 'candidate' | 'applied' | 'checked';
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const withScannedPaths = (
|
|
831
|
+
report: RegradeReport,
|
|
832
|
+
paths: readonly string[]
|
|
833
|
+
): RegradeReport => {
|
|
834
|
+
Object.defineProperty(report, 'scannedPaths', {
|
|
835
|
+
configurable: false,
|
|
836
|
+
enumerable: false,
|
|
837
|
+
value: Object.freeze([...paths]),
|
|
838
|
+
writable: false,
|
|
839
|
+
});
|
|
840
|
+
return report;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const cloneRegradeReport = (report: RegradeReport): RegradeReport => {
|
|
844
|
+
const clone = structuredClone(report);
|
|
845
|
+
return report.scannedPaths === undefined
|
|
846
|
+
? clone
|
|
847
|
+
: withScannedPaths(clone, report.scannedPaths);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
interface RegradeRewriteCandidate {
|
|
851
|
+
readonly absolutePath: string;
|
|
852
|
+
readonly classId: string;
|
|
853
|
+
readonly nextSource: string;
|
|
854
|
+
readonly path: string;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
interface RegradeClassifiedFile {
|
|
858
|
+
readonly entry: RegradeReportEntry;
|
|
859
|
+
readonly rewrite?: RegradeRewriteCandidate;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const isIgnoredByClassDirectories = (
|
|
863
|
+
path: string,
|
|
864
|
+
ignoredDirectories: readonly string[] | undefined
|
|
865
|
+
): boolean => {
|
|
866
|
+
if (ignoredDirectories === undefined || ignoredDirectories.length === 0) {
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
return path
|
|
870
|
+
.split('/')
|
|
871
|
+
.slice(0, -1)
|
|
872
|
+
.some((segment) => ignoredDirectories.includes(segment));
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const classScanTargetSkip = (
|
|
876
|
+
cls: RegradeClass,
|
|
877
|
+
path: string,
|
|
878
|
+
collection: DownstreamCollectionOptions | undefined
|
|
879
|
+
): RegradeClassResult | undefined => {
|
|
880
|
+
const ignoredDirectories =
|
|
881
|
+
collection?.ignoredDirectories ??
|
|
882
|
+
cls.scanTargets?.ignoredDirectories ??
|
|
883
|
+
DEFAULT_IGNORED_DIRECTORIES;
|
|
884
|
+
if (isIgnoredByClassDirectories(path, ignoredDirectories)) {
|
|
885
|
+
return {
|
|
886
|
+
kind: 'skipped',
|
|
887
|
+
notes: [`Skipped by ${cls.id} scan-target filtering.`],
|
|
888
|
+
reason: 'regrade-scan-target-filtered',
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const effectiveScanTargets: ScanTargets | undefined =
|
|
892
|
+
cls.scanTargets?.extensions === undefined &&
|
|
893
|
+
collection?.extensions === undefined
|
|
894
|
+
? {
|
|
895
|
+
...cls.scanTargets,
|
|
896
|
+
extensions: DEFAULT_SOURCE_EXTENSIONS,
|
|
897
|
+
}
|
|
898
|
+
: cls.scanTargets;
|
|
899
|
+
if (
|
|
900
|
+
effectiveScanTargets === undefined ||
|
|
901
|
+
includedByPathScope(path, effectiveScanTargets)
|
|
902
|
+
) {
|
|
903
|
+
return undefined;
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
kind: 'skipped',
|
|
907
|
+
notes: [`Skipped by ${cls.id} scan-target filtering.`],
|
|
908
|
+
reason: 'regrade-scan-target-filtered',
|
|
909
|
+
};
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const classifyFile = (
|
|
913
|
+
path: string,
|
|
914
|
+
source: string,
|
|
915
|
+
context: RegradeClassContext,
|
|
916
|
+
selected: readonly RegradeClass[],
|
|
917
|
+
collection?: DownstreamCollectionOptions
|
|
918
|
+
): RegradeClassifiedFile => {
|
|
919
|
+
// Compose safe rewrites across selected classes in memory so one governed
|
|
920
|
+
// transition can move every compatible symbol in a file. Review still wins:
|
|
921
|
+
// if any class needs judgment, no partial rewrite is returned for that file.
|
|
922
|
+
let skipped:
|
|
923
|
+
| { readonly classId: string; readonly result: RegradeClassResult }
|
|
924
|
+
| undefined;
|
|
925
|
+
let inspected = false;
|
|
926
|
+
let currentSource = source;
|
|
927
|
+
const rewriteClassIds: string[] = [];
|
|
928
|
+
const rewriteNotes: string[] = [];
|
|
929
|
+
for (const cls of selected) {
|
|
930
|
+
const result =
|
|
931
|
+
classScanTargetSkip(cls, path, collection) ??
|
|
932
|
+
cls.apply(currentSource, context);
|
|
933
|
+
if (result.kind !== 'skipped') {
|
|
934
|
+
inspected = true;
|
|
935
|
+
}
|
|
936
|
+
if (result.kind === 'rewrite') {
|
|
937
|
+
if (typeof result.nextSource !== 'string') {
|
|
938
|
+
return {
|
|
939
|
+
entry: {
|
|
940
|
+
classId: cls.id,
|
|
941
|
+
notes: result.notes,
|
|
942
|
+
outcome: 'needs-review',
|
|
943
|
+
path,
|
|
944
|
+
reason: 'regrade-rewrite-missing-source',
|
|
945
|
+
},
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
currentSource = result.nextSource;
|
|
949
|
+
rewriteClassIds.push(cls.id);
|
|
950
|
+
rewriteNotes.push(...(result.notes ?? []));
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (result.kind === 'needs-review') {
|
|
954
|
+
const originalSourceResult =
|
|
955
|
+
currentSource === source ? result : cls.apply(source, context);
|
|
956
|
+
const reviewResult =
|
|
957
|
+
originalSourceResult.kind === 'needs-review'
|
|
958
|
+
? originalSourceResult
|
|
959
|
+
: result;
|
|
960
|
+
const reviewDetails = reviewResult.reviewDetails?.map((detail) => ({
|
|
961
|
+
...detail,
|
|
962
|
+
classId: detail.classId ?? cls.id,
|
|
963
|
+
}));
|
|
964
|
+
return {
|
|
965
|
+
entry: {
|
|
966
|
+
classId: cls.id,
|
|
967
|
+
notes: reviewResult.notes,
|
|
968
|
+
outcome: 'needs-review',
|
|
969
|
+
path,
|
|
970
|
+
reason: reviewResult.reason ?? 'needs-review',
|
|
971
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
if (result.kind === 'skipped' && skipped === undefined) {
|
|
976
|
+
skipped = { classId: cls.id, result };
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (rewriteClassIds.length > 0) {
|
|
980
|
+
const classId = rewriteClassIds.join(',');
|
|
981
|
+
const entry = {
|
|
982
|
+
classId,
|
|
983
|
+
notes: rewriteNotes,
|
|
984
|
+
outcome: 'rewrite',
|
|
985
|
+
path,
|
|
986
|
+
} satisfies RegradeReportEntry;
|
|
987
|
+
return {
|
|
988
|
+
entry,
|
|
989
|
+
...(context.absolutePath === undefined
|
|
990
|
+
? {}
|
|
991
|
+
: {
|
|
992
|
+
rewrite: {
|
|
993
|
+
absolutePath: context.absolutePath,
|
|
994
|
+
classId,
|
|
995
|
+
nextSource: currentSource,
|
|
996
|
+
path,
|
|
997
|
+
},
|
|
998
|
+
}),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
if (!inspected && skipped !== undefined) {
|
|
1002
|
+
return {
|
|
1003
|
+
entry: {
|
|
1004
|
+
classId: skipped.classId,
|
|
1005
|
+
notes: skipped.result.notes,
|
|
1006
|
+
outcome: 'skip',
|
|
1007
|
+
path,
|
|
1008
|
+
reason: skipped.result.reason ?? 'skipped',
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
return { entry: { outcome: 'no-op', path } };
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
interface RegradeEvaluation {
|
|
1016
|
+
readonly report: RegradeReport;
|
|
1017
|
+
readonly rewrites: readonly RegradeRewriteCandidate[];
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const includeEntryInReport = (
|
|
1021
|
+
entry: RegradeReportEntry,
|
|
1022
|
+
selection: RegradeReportEntrySelection
|
|
1023
|
+
): boolean =>
|
|
1024
|
+
selection === 'all' ||
|
|
1025
|
+
entry.outcome === 'rewrite' ||
|
|
1026
|
+
entry.outcome === 'needs-review';
|
|
1027
|
+
|
|
1028
|
+
const skipsByReason = (
|
|
1029
|
+
entries: readonly RegradeReportEntry[]
|
|
1030
|
+
): Readonly<Record<string, number>> => {
|
|
1031
|
+
const counts = new Map<string, number>();
|
|
1032
|
+
for (const entry of entries) {
|
|
1033
|
+
if (entry.outcome !== 'skip') {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const reason = entry.reason ?? 'skipped';
|
|
1037
|
+
counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
|
1038
|
+
}
|
|
1039
|
+
return Object.fromEntries(
|
|
1040
|
+
[...counts.entries()].toSorted(([left], [right]) =>
|
|
1041
|
+
left.localeCompare(right)
|
|
1042
|
+
)
|
|
1043
|
+
);
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Build a coverage report from already-read source files. Pure: no filesystem
|
|
1048
|
+
* access, so coverage semantics are testable directly.
|
|
1049
|
+
*/
|
|
1050
|
+
const buildRegradeEvaluation = (params: {
|
|
1051
|
+
readonly root: string;
|
|
1052
|
+
readonly files: readonly {
|
|
1053
|
+
readonly path: string;
|
|
1054
|
+
readonly source: string;
|
|
1055
|
+
readonly absolutePath?: string;
|
|
1056
|
+
readonly package?: RegradeClassContext['package'];
|
|
1057
|
+
}[];
|
|
1058
|
+
readonly skipped: readonly SkippedSource[];
|
|
1059
|
+
readonly classes: readonly RegradeClass[];
|
|
1060
|
+
readonly selection?: RegradeSelection;
|
|
1061
|
+
readonly collection?: DownstreamCollectionOptions;
|
|
1062
|
+
readonly includeEntries?: RegradeReportEntrySelection;
|
|
1063
|
+
}): RegradeEvaluation => {
|
|
1064
|
+
const entrySelection = params.includeEntries ?? 'actionable';
|
|
1065
|
+
const { selected, unknownClassIds } = selectRegradeClasses(
|
|
1066
|
+
params.classes,
|
|
1067
|
+
params.selection
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const classifiedFiles = params.files.map((file) =>
|
|
1071
|
+
classifyFile(
|
|
1072
|
+
file.path,
|
|
1073
|
+
file.source,
|
|
1074
|
+
{
|
|
1075
|
+
...(file.absolutePath === undefined
|
|
1076
|
+
? {}
|
|
1077
|
+
: { absolutePath: file.absolutePath }),
|
|
1078
|
+
...(file.package === undefined ? {} : { package: file.package }),
|
|
1079
|
+
path: file.path,
|
|
1080
|
+
},
|
|
1081
|
+
selected,
|
|
1082
|
+
params.collection
|
|
1083
|
+
)
|
|
1084
|
+
);
|
|
1085
|
+
const fileEntries = classifiedFiles.map((file) => file.entry);
|
|
1086
|
+
const rewrites = classifiedFiles.flatMap((file) =>
|
|
1087
|
+
file.rewrite === undefined ? [] : [file.rewrite]
|
|
1088
|
+
);
|
|
1089
|
+
const skipEntries: RegradeReportEntry[] = params.skipped.map((entry) => ({
|
|
1090
|
+
outcome: 'skip',
|
|
1091
|
+
path: entry.path,
|
|
1092
|
+
reason: entry.reason,
|
|
1093
|
+
}));
|
|
1094
|
+
|
|
1095
|
+
const allEntries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
|
|
1096
|
+
a.path.localeCompare(b.path)
|
|
1097
|
+
);
|
|
1098
|
+
const entries = allEntries.filter((entry) =>
|
|
1099
|
+
includeEntryInReport(entry, entrySelection)
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
// Class-level skips (e.g. scan-target filtering) are accounted as skipped, not
|
|
1103
|
+
// as scanned/clean files.
|
|
1104
|
+
const scannedEntries = fileEntries.filter((e) => e.outcome !== 'skip');
|
|
1105
|
+
const fileSkipCount = fileEntries.length - scannedEntries.length;
|
|
1106
|
+
const rewritten = scannedEntries.filter(
|
|
1107
|
+
(e) => e.outcome === 'rewrite'
|
|
1108
|
+
).length;
|
|
1109
|
+
const review = scannedEntries.filter(
|
|
1110
|
+
(e) => e.outcome === 'needs-review'
|
|
1111
|
+
).length;
|
|
1112
|
+
const matchedPaths = scannedEntries
|
|
1113
|
+
.filter((e) => e.outcome === 'rewrite' || e.outcome === 'needs-review')
|
|
1114
|
+
.map((entry) => entry.path);
|
|
1115
|
+
const skipped = skipEntries.length + fileSkipCount;
|
|
1116
|
+
const skippedReasons = skipsByReason(allEntries);
|
|
1117
|
+
|
|
1118
|
+
return {
|
|
1119
|
+
report: withScannedPaths(
|
|
1120
|
+
{
|
|
1121
|
+
entries,
|
|
1122
|
+
matched: rewritten + review,
|
|
1123
|
+
review,
|
|
1124
|
+
rewritten,
|
|
1125
|
+
root: params.root,
|
|
1126
|
+
scan: buildRegradeScanSummary({
|
|
1127
|
+
matchedPaths,
|
|
1128
|
+
scanned: scannedEntries.length,
|
|
1129
|
+
skipped,
|
|
1130
|
+
skippedByReason: skippedReasons,
|
|
1131
|
+
}),
|
|
1132
|
+
scanned: scannedEntries.length,
|
|
1133
|
+
selectedClassIds: selected.map((cls) => cls.id),
|
|
1134
|
+
skipped,
|
|
1135
|
+
skipsByReason: skippedReasons,
|
|
1136
|
+
unknownClassIds,
|
|
1137
|
+
},
|
|
1138
|
+
scannedEntries.map((entry) => entry.path)
|
|
1139
|
+
),
|
|
1140
|
+
rewrites,
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
export const buildRegradeReport = (params: {
|
|
1145
|
+
readonly root: string;
|
|
1146
|
+
readonly files: readonly {
|
|
1147
|
+
readonly path: string;
|
|
1148
|
+
readonly source: string;
|
|
1149
|
+
readonly absolutePath?: string;
|
|
1150
|
+
readonly package?: RegradeClassContext['package'];
|
|
1151
|
+
}[];
|
|
1152
|
+
readonly skipped: readonly SkippedSource[];
|
|
1153
|
+
readonly classes: readonly RegradeClass[];
|
|
1154
|
+
readonly selection?: RegradeSelection;
|
|
1155
|
+
readonly collection?: DownstreamCollectionOptions;
|
|
1156
|
+
readonly includeEntries?: RegradeReportEntrySelection;
|
|
1157
|
+
}): RegradeReport => buildRegradeEvaluation(params).report;
|
|
1158
|
+
|
|
1159
|
+
const applyRegradeEvaluation = (
|
|
1160
|
+
evaluation: RegradeEvaluation
|
|
1161
|
+
): Result<RegradeApplySummary, InternalError> => {
|
|
1162
|
+
if (evaluation.report.unknownClassIds.length > 0) {
|
|
1163
|
+
return Result.ok({
|
|
1164
|
+
applied: 0,
|
|
1165
|
+
filesChanged: 0,
|
|
1166
|
+
review: evaluation.report.review,
|
|
1167
|
+
skipped: evaluation.report.skipped + evaluation.rewrites.length,
|
|
1168
|
+
unknown: evaluation.report.unknownClassIds.length,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const changedFiles = new Set<string>();
|
|
1173
|
+
let applied = 0;
|
|
1174
|
+
for (const rewrite of evaluation.rewrites) {
|
|
1175
|
+
try {
|
|
1176
|
+
writeFileSync(rewrite.absolutePath, rewrite.nextSource, 'utf8');
|
|
1177
|
+
} catch (error: unknown) {
|
|
1178
|
+
return Result.err(
|
|
1179
|
+
new InternalError(
|
|
1180
|
+
`Failed to apply regrade rewrite for "${rewrite.path}".`,
|
|
1181
|
+
{
|
|
1182
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
1183
|
+
context: {
|
|
1184
|
+
applied,
|
|
1185
|
+
classId: rewrite.classId,
|
|
1186
|
+
filesChanged: changedFiles.size,
|
|
1187
|
+
path: rewrite.path,
|
|
1188
|
+
},
|
|
1189
|
+
}
|
|
1190
|
+
)
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
applied += 1;
|
|
1194
|
+
changedFiles.add(rewrite.path);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return Result.ok({
|
|
1198
|
+
applied: evaluation.rewrites.length,
|
|
1199
|
+
filesChanged: changedFiles.size,
|
|
1200
|
+
review: evaluation.report.review,
|
|
1201
|
+
skipped: evaluation.report.skipped,
|
|
1202
|
+
unknown: 0,
|
|
1203
|
+
});
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const withApplySummary = (
|
|
1207
|
+
report: RegradeReport,
|
|
1208
|
+
apply: RegradeApplySummary
|
|
1209
|
+
): RegradeReport => {
|
|
1210
|
+
const appliedReport: RegradeReport = {
|
|
1211
|
+
...report,
|
|
1212
|
+
apply,
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
return report.scannedPaths === undefined
|
|
1216
|
+
? appliedReport
|
|
1217
|
+
: withScannedPaths(appliedReport, report.scannedPaths);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
const canReadDownstreamRoot = (root: string): boolean => {
|
|
1221
|
+
try {
|
|
1222
|
+
readdirSync(root, { withFileTypes: true });
|
|
1223
|
+
return true;
|
|
1224
|
+
} catch {
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
const PACKAGE_DEPENDENCY_FIELDS = [
|
|
1230
|
+
'dependencies',
|
|
1231
|
+
'devDependencies',
|
|
1232
|
+
'optionalDependencies',
|
|
1233
|
+
'peerDependencies',
|
|
1234
|
+
] as const;
|
|
1235
|
+
|
|
1236
|
+
const RUNTIME_PACKAGE_DEPENDENCY_FIELDS = [
|
|
1237
|
+
'dependencies',
|
|
1238
|
+
'optionalDependencies',
|
|
1239
|
+
'peerDependencies',
|
|
1240
|
+
] as const;
|
|
1241
|
+
|
|
1242
|
+
const dependencyNames = (
|
|
1243
|
+
manifest: Readonly<Record<string, unknown>>,
|
|
1244
|
+
fields: readonly (typeof PACKAGE_DEPENDENCY_FIELDS)[number][]
|
|
1245
|
+
): readonly string[] =>
|
|
1246
|
+
fields.flatMap((field) => {
|
|
1247
|
+
const value = manifest[field];
|
|
1248
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1249
|
+
? Object.keys(value)
|
|
1250
|
+
: [];
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
const packageContextFor = (
|
|
1254
|
+
root: string,
|
|
1255
|
+
absolutePath: string,
|
|
1256
|
+
cache: Map<string, RegradeClassContext['package'] | undefined>
|
|
1257
|
+
): RegradeClassContext['package'] | undefined => {
|
|
1258
|
+
const absoluteRoot = resolve(root);
|
|
1259
|
+
let current = dirname(absolutePath);
|
|
1260
|
+
const visited: string[] = [];
|
|
1261
|
+
while (true) {
|
|
1262
|
+
if (cache.has(current)) {
|
|
1263
|
+
const cached = cache.get(current);
|
|
1264
|
+
for (const directory of visited) {
|
|
1265
|
+
cache.set(directory, cached);
|
|
1266
|
+
}
|
|
1267
|
+
return cached;
|
|
1268
|
+
}
|
|
1269
|
+
visited.push(current);
|
|
1270
|
+
const fromRoot = relative(absoluteRoot, current);
|
|
1271
|
+
if (fromRoot.startsWith('..') || resolve(current) !== current) {
|
|
1272
|
+
break;
|
|
1273
|
+
}
|
|
1274
|
+
const manifestPath = join(current, 'package.json');
|
|
1275
|
+
if (existsSync(manifestPath)) {
|
|
1276
|
+
const manifestRelativePath = relative(
|
|
1277
|
+
absoluteRoot,
|
|
1278
|
+
manifestPath
|
|
1279
|
+
).replaceAll('\\', '/');
|
|
1280
|
+
try {
|
|
1281
|
+
const parsed = JSON.parse(
|
|
1282
|
+
readFileSync(manifestPath, 'utf8')
|
|
1283
|
+
) as unknown;
|
|
1284
|
+
if (
|
|
1285
|
+
typeof parsed !== 'object' ||
|
|
1286
|
+
parsed === null ||
|
|
1287
|
+
Array.isArray(parsed)
|
|
1288
|
+
) {
|
|
1289
|
+
const invalidContext = {
|
|
1290
|
+
dependencies: [],
|
|
1291
|
+
manifestState: 'invalid' as const,
|
|
1292
|
+
path: manifestRelativePath,
|
|
1293
|
+
};
|
|
1294
|
+
for (const directory of visited) {
|
|
1295
|
+
cache.set(directory, invalidContext);
|
|
1296
|
+
}
|
|
1297
|
+
return invalidContext;
|
|
1298
|
+
}
|
|
1299
|
+
const manifest = parsed as Record<string, unknown>;
|
|
1300
|
+
const dependencies = dependencyNames(
|
|
1301
|
+
manifest,
|
|
1302
|
+
PACKAGE_DEPENDENCY_FIELDS
|
|
1303
|
+
);
|
|
1304
|
+
const runtimeDependencies = dependencyNames(
|
|
1305
|
+
manifest,
|
|
1306
|
+
RUNTIME_PACKAGE_DEPENDENCY_FIELDS
|
|
1307
|
+
);
|
|
1308
|
+
const packageContext = {
|
|
1309
|
+
dependencies: [...new Set(dependencies)].toSorted(),
|
|
1310
|
+
manifestState: 'valid' as const,
|
|
1311
|
+
...(typeof manifest['name'] === 'string'
|
|
1312
|
+
? { name: manifest['name'] }
|
|
1313
|
+
: {}),
|
|
1314
|
+
path: manifestRelativePath,
|
|
1315
|
+
runtimeDependencies: [...new Set(runtimeDependencies)].toSorted(),
|
|
1316
|
+
};
|
|
1317
|
+
for (const directory of visited) {
|
|
1318
|
+
cache.set(directory, packageContext);
|
|
1319
|
+
}
|
|
1320
|
+
return packageContext;
|
|
1321
|
+
} catch {
|
|
1322
|
+
const invalidContext = {
|
|
1323
|
+
dependencies: [],
|
|
1324
|
+
manifestState: 'invalid' as const,
|
|
1325
|
+
path: manifestRelativePath,
|
|
1326
|
+
};
|
|
1327
|
+
for (const directory of visited) {
|
|
1328
|
+
cache.set(directory, invalidContext);
|
|
1329
|
+
}
|
|
1330
|
+
return invalidContext;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (current === absoluteRoot) {
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1336
|
+
current = dirname(current);
|
|
1337
|
+
}
|
|
1338
|
+
for (const directory of visited) {
|
|
1339
|
+
cache.set(directory, undefined);
|
|
1340
|
+
}
|
|
1341
|
+
return undefined;
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
interface RegradeSourceState {
|
|
1345
|
+
readonly hash: string;
|
|
1346
|
+
readonly unreadable: readonly string[];
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
interface CollectedRegradeEvaluation {
|
|
1350
|
+
readonly evaluation: RegradeEvaluation;
|
|
1351
|
+
readonly sourceState: RegradeSourceState;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
interface PrepareRegradeRunParams {
|
|
1355
|
+
readonly root: string;
|
|
1356
|
+
readonly classes: readonly RegradeClass[];
|
|
1357
|
+
readonly selection?: RegradeSelection;
|
|
1358
|
+
readonly collection?: DownstreamCollectionOptions;
|
|
1359
|
+
readonly includeEntries?: RegradeReportEntrySelection;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const snapshotPrepareRegradeRunParams = (
|
|
1363
|
+
params: PrepareRegradeRunParams
|
|
1364
|
+
): PrepareRegradeRunParams => ({
|
|
1365
|
+
classes: params.classes.map((regradeClass) => ({
|
|
1366
|
+
...regradeClass,
|
|
1367
|
+
...(regradeClass.scanTargets === undefined
|
|
1368
|
+
? {}
|
|
1369
|
+
: {
|
|
1370
|
+
scanTargets: {
|
|
1371
|
+
...regradeClass.scanTargets,
|
|
1372
|
+
...(regradeClass.scanTargets.exclude === undefined
|
|
1373
|
+
? {}
|
|
1374
|
+
: { exclude: [...regradeClass.scanTargets.exclude] }),
|
|
1375
|
+
...(regradeClass.scanTargets.extensions === undefined
|
|
1376
|
+
? {}
|
|
1377
|
+
: { extensions: [...regradeClass.scanTargets.extensions] }),
|
|
1378
|
+
...(regradeClass.scanTargets.ignoredDirectories === undefined
|
|
1379
|
+
? {}
|
|
1380
|
+
: {
|
|
1381
|
+
ignoredDirectories: [
|
|
1382
|
+
...regradeClass.scanTargets.ignoredDirectories,
|
|
1383
|
+
],
|
|
1384
|
+
}),
|
|
1385
|
+
},
|
|
1386
|
+
}),
|
|
1387
|
+
})),
|
|
1388
|
+
...(params.collection === undefined
|
|
1389
|
+
? {}
|
|
1390
|
+
: {
|
|
1391
|
+
collection: {
|
|
1392
|
+
...params.collection,
|
|
1393
|
+
...(params.collection.exclude === undefined
|
|
1394
|
+
? {}
|
|
1395
|
+
: { exclude: [...params.collection.exclude] }),
|
|
1396
|
+
...(params.collection.extensions === undefined
|
|
1397
|
+
? {}
|
|
1398
|
+
: { extensions: [...params.collection.extensions] }),
|
|
1399
|
+
...(params.collection.ignoredDirectories === undefined
|
|
1400
|
+
? {}
|
|
1401
|
+
: {
|
|
1402
|
+
ignoredDirectories: [...params.collection.ignoredDirectories],
|
|
1403
|
+
}),
|
|
1404
|
+
...(params.collection.include === undefined
|
|
1405
|
+
? {}
|
|
1406
|
+
: { include: [...params.collection.include] }),
|
|
1407
|
+
},
|
|
1408
|
+
}),
|
|
1409
|
+
...(params.includeEntries === undefined
|
|
1410
|
+
? {}
|
|
1411
|
+
: { includeEntries: params.includeEntries }),
|
|
1412
|
+
root: params.root,
|
|
1413
|
+
...(params.selection === undefined
|
|
1414
|
+
? {}
|
|
1415
|
+
: {
|
|
1416
|
+
selection:
|
|
1417
|
+
params.selection.classIds === undefined
|
|
1418
|
+
? {}
|
|
1419
|
+
: { classIds: [...params.selection.classIds] },
|
|
1420
|
+
}),
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
const compareRegradeCodeUnits = (left: string, right: string): number => {
|
|
1424
|
+
if (left < right) {
|
|
1425
|
+
return -1;
|
|
1426
|
+
}
|
|
1427
|
+
if (left > right) {
|
|
1428
|
+
return 1;
|
|
1429
|
+
}
|
|
1430
|
+
return 0;
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
const regradeSourceStateHash = (state: {
|
|
1434
|
+
readonly manifests: readonly {
|
|
1435
|
+
readonly bytes: string;
|
|
1436
|
+
readonly path: string;
|
|
1437
|
+
}[];
|
|
1438
|
+
readonly sources: readonly {
|
|
1439
|
+
readonly bytes: string;
|
|
1440
|
+
readonly path: string;
|
|
1441
|
+
}[];
|
|
1442
|
+
}): string => createHash('sha256').update(JSON.stringify(state)).digest('hex');
|
|
1443
|
+
|
|
1444
|
+
const collectRegradeEvaluation = (
|
|
1445
|
+
params: PrepareRegradeRunParams
|
|
1446
|
+
): CollectedRegradeEvaluation | null => {
|
|
1447
|
+
const { selected, unknownClassIds } = selectRegradeClasses(
|
|
1448
|
+
params.classes,
|
|
1449
|
+
params.selection
|
|
1450
|
+
);
|
|
1451
|
+
if (selected.length === 0 && unknownClassIds.length > 0) {
|
|
1452
|
+
if (!canReadDownstreamRoot(params.root)) {
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
evaluation: buildRegradeEvaluation({
|
|
1457
|
+
classes: params.classes,
|
|
1458
|
+
...(params.collection === undefined
|
|
1459
|
+
? {}
|
|
1460
|
+
: { collection: params.collection }),
|
|
1461
|
+
files: [],
|
|
1462
|
+
root: params.root,
|
|
1463
|
+
skipped: [],
|
|
1464
|
+
...(params.includeEntries === undefined
|
|
1465
|
+
? {}
|
|
1466
|
+
: { includeEntries: params.includeEntries }),
|
|
1467
|
+
...(params.selection === undefined
|
|
1468
|
+
? {}
|
|
1469
|
+
: { selection: params.selection }),
|
|
1470
|
+
}),
|
|
1471
|
+
sourceState: {
|
|
1472
|
+
hash: regradeSourceStateHash({ manifests: [], sources: [] }),
|
|
1473
|
+
unreadable: [],
|
|
1474
|
+
},
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const collected = collectDownstreamSources(
|
|
1479
|
+
params.root,
|
|
1480
|
+
deriveCollectionOptions(selected, params.collection)
|
|
1481
|
+
);
|
|
1482
|
+
if (collected === null) {
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const files: { absolutePath: string; path: string; source: string }[] = [];
|
|
1487
|
+
const skipped: SkippedSource[] = [...collected.skipped];
|
|
1488
|
+
const sourceBytes: { readonly bytes: string; readonly path: string }[] = [];
|
|
1489
|
+
const unreadable = new Set(
|
|
1490
|
+
collected.skipped
|
|
1491
|
+
.filter((entry) => entry.reason === 'unreadable-directory')
|
|
1492
|
+
.map((entry) => entry.path)
|
|
1493
|
+
);
|
|
1494
|
+
const manifestPaths = new Set<string>();
|
|
1495
|
+
const packageContextCache = new Map<
|
|
1496
|
+
string,
|
|
1497
|
+
RegradeClassContext['package'] | undefined
|
|
1498
|
+
>();
|
|
1499
|
+
for (const file of collected.files) {
|
|
1500
|
+
try {
|
|
1501
|
+
const bytes = readFileSync(file.absolutePath);
|
|
1502
|
+
const packageContext = packageContextFor(
|
|
1503
|
+
params.root,
|
|
1504
|
+
file.absolutePath,
|
|
1505
|
+
packageContextCache
|
|
1506
|
+
);
|
|
1507
|
+
if (packageContext !== undefined) {
|
|
1508
|
+
manifestPaths.add(packageContext.path);
|
|
1509
|
+
}
|
|
1510
|
+
files.push({
|
|
1511
|
+
absolutePath: file.absolutePath,
|
|
1512
|
+
...(packageContext === undefined ? {} : { package: packageContext }),
|
|
1513
|
+
path: file.path,
|
|
1514
|
+
source: bytes.toString('utf8'),
|
|
1515
|
+
});
|
|
1516
|
+
sourceBytes.push({ bytes: bytes.toString('base64'), path: file.path });
|
|
1517
|
+
} catch {
|
|
1518
|
+
skipped.push({ path: file.path, reason: 'unreadable-file' });
|
|
1519
|
+
unreadable.add(file.path);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const manifestBytes = [...manifestPaths].toSorted().flatMap((path) => {
|
|
1524
|
+
try {
|
|
1525
|
+
return [
|
|
1526
|
+
{
|
|
1527
|
+
bytes: readFileSync(join(resolve(params.root), path)).toString(
|
|
1528
|
+
'base64'
|
|
1529
|
+
),
|
|
1530
|
+
path,
|
|
1531
|
+
},
|
|
1532
|
+
];
|
|
1533
|
+
} catch {
|
|
1534
|
+
unreadable.add(path);
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
return {
|
|
1540
|
+
evaluation: buildRegradeEvaluation({
|
|
1541
|
+
classes: params.classes,
|
|
1542
|
+
...(params.collection === undefined
|
|
1543
|
+
? {}
|
|
1544
|
+
: { collection: params.collection }),
|
|
1545
|
+
files,
|
|
1546
|
+
root: params.root,
|
|
1547
|
+
skipped,
|
|
1548
|
+
...(params.includeEntries === undefined
|
|
1549
|
+
? {}
|
|
1550
|
+
: { includeEntries: params.includeEntries }),
|
|
1551
|
+
...(params.selection === undefined
|
|
1552
|
+
? {}
|
|
1553
|
+
: { selection: params.selection }),
|
|
1554
|
+
}),
|
|
1555
|
+
sourceState: {
|
|
1556
|
+
hash: regradeSourceStateHash({
|
|
1557
|
+
manifests: manifestBytes,
|
|
1558
|
+
sources: sourceBytes.toSorted((left, right) =>
|
|
1559
|
+
compareRegradeCodeUnits(left.path, right.path)
|
|
1560
|
+
),
|
|
1561
|
+
}),
|
|
1562
|
+
unreadable: [...unreadable].toSorted(),
|
|
1563
|
+
},
|
|
1564
|
+
};
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
const collectRegradeSourceState = (
|
|
1568
|
+
params: PrepareRegradeRunParams
|
|
1569
|
+
): RegradeSourceState | null => {
|
|
1570
|
+
const { selected, unknownClassIds } = selectRegradeClasses(
|
|
1571
|
+
params.classes,
|
|
1572
|
+
params.selection
|
|
1573
|
+
);
|
|
1574
|
+
if (selected.length === 0 && unknownClassIds.length > 0) {
|
|
1575
|
+
return canReadDownstreamRoot(params.root)
|
|
1576
|
+
? {
|
|
1577
|
+
hash: regradeSourceStateHash({ manifests: [], sources: [] }),
|
|
1578
|
+
unreadable: [],
|
|
1579
|
+
}
|
|
1580
|
+
: null;
|
|
1581
|
+
}
|
|
1582
|
+
const collected = collectDownstreamSources(
|
|
1583
|
+
params.root,
|
|
1584
|
+
deriveCollectionOptions(selected, params.collection)
|
|
1585
|
+
);
|
|
1586
|
+
if (collected === null) {
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
const sourceBytes: { readonly bytes: string; readonly path: string }[] = [];
|
|
1590
|
+
const unreadable = new Set(
|
|
1591
|
+
collected.skipped
|
|
1592
|
+
.filter((entry) => entry.reason === 'unreadable-directory')
|
|
1593
|
+
.map((entry) => entry.path)
|
|
1594
|
+
);
|
|
1595
|
+
const manifestPaths = new Set<string>();
|
|
1596
|
+
const packageContextCache = new Map<
|
|
1597
|
+
string,
|
|
1598
|
+
RegradeClassContext['package'] | undefined
|
|
1599
|
+
>();
|
|
1600
|
+
for (const file of collected.files) {
|
|
1601
|
+
try {
|
|
1602
|
+
const bytes = readFileSync(file.absolutePath);
|
|
1603
|
+
sourceBytes.push({ bytes: bytes.toString('base64'), path: file.path });
|
|
1604
|
+
const packageContext = packageContextFor(
|
|
1605
|
+
params.root,
|
|
1606
|
+
file.absolutePath,
|
|
1607
|
+
packageContextCache
|
|
1608
|
+
);
|
|
1609
|
+
if (packageContext !== undefined) {
|
|
1610
|
+
manifestPaths.add(packageContext.path);
|
|
1611
|
+
}
|
|
1612
|
+
} catch {
|
|
1613
|
+
unreadable.add(file.path);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
const manifestBytes = [...manifestPaths]
|
|
1617
|
+
.toSorted(compareRegradeCodeUnits)
|
|
1618
|
+
.flatMap((path) => {
|
|
1619
|
+
try {
|
|
1620
|
+
return [
|
|
1621
|
+
{
|
|
1622
|
+
bytes: readFileSync(join(resolve(params.root), path)).toString(
|
|
1623
|
+
'base64'
|
|
1624
|
+
),
|
|
1625
|
+
path,
|
|
1626
|
+
},
|
|
1627
|
+
];
|
|
1628
|
+
} catch {
|
|
1629
|
+
unreadable.add(path);
|
|
1630
|
+
return [];
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
return {
|
|
1634
|
+
hash: regradeSourceStateHash({
|
|
1635
|
+
manifests: manifestBytes,
|
|
1636
|
+
sources: sourceBytes.toSorted((left, right) =>
|
|
1637
|
+
compareRegradeCodeUnits(left.path, right.path)
|
|
1638
|
+
),
|
|
1639
|
+
}),
|
|
1640
|
+
unreadable: [...unreadable].toSorted(compareRegradeCodeUnits),
|
|
1641
|
+
};
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
interface PreparedRegradeRunState {
|
|
1645
|
+
readonly evaluation: RegradeEvaluation;
|
|
1646
|
+
readonly identity: PreparedRegradeRunIdentity;
|
|
1647
|
+
readonly params: PrepareRegradeRunParams;
|
|
1648
|
+
readonly report: RegradeReport;
|
|
1649
|
+
readonly sourceStateHash: string;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const preparedRegradeRunStates = new WeakMap<
|
|
1653
|
+
PreparedRegradeRun,
|
|
1654
|
+
PreparedRegradeRunState
|
|
1655
|
+
>();
|
|
1656
|
+
|
|
1657
|
+
const validatePreparedIdentity = (
|
|
1658
|
+
expected: PreparedRegradeRunIdentity,
|
|
1659
|
+
actual: PreparedRegradeRunIdentity
|
|
1660
|
+
): Result<void, ValidationError> => {
|
|
1661
|
+
for (const field of [
|
|
1662
|
+
'planContentHash',
|
|
1663
|
+
'policyHash',
|
|
1664
|
+
'scopeHash',
|
|
1665
|
+
'lockStateHash',
|
|
1666
|
+
'toolVersion',
|
|
1667
|
+
] as const) {
|
|
1668
|
+
if (expected[field] !== actual[field]) {
|
|
1669
|
+
return Result.err(
|
|
1670
|
+
new ValidationError(
|
|
1671
|
+
`Prepared Regrade identity field \`${field}\` is stale.`,
|
|
1672
|
+
{
|
|
1673
|
+
context: {
|
|
1674
|
+
actual: actual[field],
|
|
1675
|
+
expected: expected[field],
|
|
1676
|
+
field,
|
|
1677
|
+
},
|
|
1678
|
+
}
|
|
1679
|
+
)
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
return Result.ok();
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Classify a class/symbol Regrade run once and retain it for checked apply.
|
|
1688
|
+
*
|
|
1689
|
+
* @example
|
|
1690
|
+
* ```ts
|
|
1691
|
+
* const prepared = prepareRegradeRun({ classes, identity, root });
|
|
1692
|
+
* if (prepared.isErr()) throw prepared.error;
|
|
1693
|
+
* ```
|
|
1694
|
+
*/
|
|
1695
|
+
export const prepareRegradeRun = (
|
|
1696
|
+
params: PrepareRegradeRunParams & {
|
|
1697
|
+
readonly identity: PreparedRegradeRunIdentity;
|
|
1698
|
+
}
|
|
1699
|
+
): Result<PreparedRegradeRun | null, ValidationError> => {
|
|
1700
|
+
const preparedParams = snapshotPrepareRegradeRunParams(params);
|
|
1701
|
+
const collected = collectRegradeEvaluation(preparedParams);
|
|
1702
|
+
if (collected === null) {
|
|
1703
|
+
return Result.ok(null);
|
|
1704
|
+
}
|
|
1705
|
+
if (collected.sourceState.unreadable.length > 0) {
|
|
1706
|
+
return Result.err(
|
|
1707
|
+
new ValidationError('Prepared Regrade sources must all be readable.', {
|
|
1708
|
+
context: { paths: collected.sourceState.unreadable },
|
|
1709
|
+
})
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
const prepared: PreparedRegradeRun = {
|
|
1713
|
+
identity: { ...params.identity },
|
|
1714
|
+
report: cloneRegradeReport(collected.evaluation.report),
|
|
1715
|
+
sourceStateHash: collected.sourceState.hash,
|
|
1716
|
+
};
|
|
1717
|
+
preparedRegradeRunStates.set(prepared, {
|
|
1718
|
+
evaluation: collected.evaluation,
|
|
1719
|
+
identity: { ...params.identity },
|
|
1720
|
+
params: preparedParams,
|
|
1721
|
+
report: collected.evaluation.report,
|
|
1722
|
+
sourceStateHash: collected.sourceState.hash,
|
|
1723
|
+
});
|
|
1724
|
+
return Result.ok(prepared);
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Validate that an in-memory class/symbol evaluation still matches its
|
|
1729
|
+
* authored identity and source bytes without applying it.
|
|
1730
|
+
*
|
|
1731
|
+
* @example
|
|
1732
|
+
* ```ts
|
|
1733
|
+
* const current = validatePreparedRegradeRun(prepared, identity);
|
|
1734
|
+
* if (current.isErr()) throw current.error;
|
|
1735
|
+
* ```
|
|
1736
|
+
*/
|
|
1737
|
+
export const validatePreparedRegradeRun = (
|
|
1738
|
+
prepared: PreparedRegradeRun,
|
|
1739
|
+
identity: PreparedRegradeRunIdentity
|
|
1740
|
+
): Result<void, ValidationError> => {
|
|
1741
|
+
const state = preparedRegradeRunStates.get(prepared);
|
|
1742
|
+
if (state === undefined) {
|
|
1743
|
+
return Result.err(
|
|
1744
|
+
new ValidationError(
|
|
1745
|
+
'Prepared Regrade run is not the original in-memory evaluation.'
|
|
1746
|
+
)
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
const identityValidation = validatePreparedIdentity(state.identity, identity);
|
|
1750
|
+
if (identityValidation.isErr()) {
|
|
1751
|
+
return identityValidation;
|
|
1752
|
+
}
|
|
1753
|
+
const current = collectRegradeSourceState(state.params);
|
|
1754
|
+
if (current === null) {
|
|
1755
|
+
return Result.err(
|
|
1756
|
+
new ValidationError('Prepared Regrade root is no longer readable.')
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
if (current.unreadable.length > 0) {
|
|
1760
|
+
return Result.err(
|
|
1761
|
+
new ValidationError('Prepared Regrade sources are no longer readable.', {
|
|
1762
|
+
context: { paths: current.unreadable },
|
|
1763
|
+
})
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
if (current.hash !== state.sourceStateHash) {
|
|
1767
|
+
return Result.err(
|
|
1768
|
+
new ValidationError('Prepared Regrade source state is stale.', {
|
|
1769
|
+
context: {
|
|
1770
|
+
actual: current.hash,
|
|
1771
|
+
expected: state.sourceStateHash,
|
|
1772
|
+
},
|
|
1773
|
+
})
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
return Result.ok();
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Apply an in-memory class/symbol evaluation after identity and source checks.
|
|
1781
|
+
*
|
|
1782
|
+
* @example
|
|
1783
|
+
* ```ts
|
|
1784
|
+
* const applied = applyPreparedRegradeRun(prepared, identity);
|
|
1785
|
+
* if (applied.isErr()) throw applied.error;
|
|
1786
|
+
* ```
|
|
1787
|
+
*/
|
|
1788
|
+
export const applyPreparedRegradeRun = (
|
|
1789
|
+
prepared: PreparedRegradeRun,
|
|
1790
|
+
identity: PreparedRegradeRunIdentity
|
|
1791
|
+
): Result<RegradeReport, InternalError | ValidationError> => {
|
|
1792
|
+
const state = preparedRegradeRunStates.get(prepared);
|
|
1793
|
+
const validation = validatePreparedRegradeRun(prepared, identity);
|
|
1794
|
+
if (validation.isErr()) {
|
|
1795
|
+
return validation;
|
|
1796
|
+
}
|
|
1797
|
+
if (state === undefined) {
|
|
1798
|
+
return Result.err(
|
|
1799
|
+
new ValidationError(
|
|
1800
|
+
'Prepared Regrade run is not the original in-memory evaluation.'
|
|
1801
|
+
)
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
const applyResult = applyRegradeEvaluation(state.evaluation);
|
|
1805
|
+
if (applyResult.isErr()) {
|
|
1806
|
+
return applyResult;
|
|
1807
|
+
}
|
|
1808
|
+
return Result.ok(withApplySummary(state.report, applyResult.value));
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* Run a regrade over an explicit downstream root.
|
|
1813
|
+
*
|
|
1814
|
+
* Dry-run is the default and only reports candidate rewrites. Explicit apply
|
|
1815
|
+
* mode writes safe rewrite outcomes with concrete `nextSource` payloads and
|
|
1816
|
+
* summarizes what was written or intentionally skipped.
|
|
1817
|
+
*/
|
|
1818
|
+
export const runRegrade = (params: {
|
|
1819
|
+
readonly root: string;
|
|
1820
|
+
readonly classes: readonly RegradeClass[];
|
|
1821
|
+
readonly selection?: RegradeSelection;
|
|
1822
|
+
readonly collection?: DownstreamCollectionOptions;
|
|
1823
|
+
readonly apply?: boolean;
|
|
1824
|
+
readonly includeEntries?: RegradeReportEntrySelection;
|
|
1825
|
+
}): Result<RegradeReport | null, InternalError> => {
|
|
1826
|
+
const collected = collectRegradeEvaluation(params);
|
|
1827
|
+
if (collected === null) {
|
|
1828
|
+
return Result.ok(null);
|
|
1829
|
+
}
|
|
1830
|
+
const { evaluation } = collected;
|
|
1831
|
+
|
|
1832
|
+
if (params.apply !== true) {
|
|
1833
|
+
return Result.ok(evaluation.report);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const applyResult = applyRegradeEvaluation(evaluation);
|
|
1837
|
+
if (applyResult.isErr()) {
|
|
1838
|
+
return applyResult;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return Result.ok(withApplySummary(evaluation.report, applyResult.value));
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
const regradeReportEntrySchema = z.object({
|
|
1845
|
+
classId: z.string().optional().describe('Class that produced the outcome'),
|
|
1846
|
+
notes: z.array(z.string()).optional().describe('Notes from the class'),
|
|
1847
|
+
outcome: z
|
|
1848
|
+
.enum(['needs-review', 'no-op', 'rewrite', 'skip'])
|
|
1849
|
+
.describe('What happened to the entry'),
|
|
1850
|
+
path: z.string().describe('Root-relative POSIX path'),
|
|
1851
|
+
reason: z.string().optional().describe('Reason for a skip or review outcome'),
|
|
1852
|
+
reviewDetails: z
|
|
1853
|
+
.array(
|
|
1854
|
+
z.object({
|
|
1855
|
+
candidateReplacement: z
|
|
1856
|
+
.string()
|
|
1857
|
+
.optional()
|
|
1858
|
+
.describe(
|
|
1859
|
+
'Concrete replacement the class would apply if the occurrence were judged safe'
|
|
1860
|
+
),
|
|
1861
|
+
classId: z
|
|
1862
|
+
.string()
|
|
1863
|
+
.optional()
|
|
1864
|
+
.describe('Class that produced the review detail'),
|
|
1865
|
+
context: z
|
|
1866
|
+
.string()
|
|
1867
|
+
.optional()
|
|
1868
|
+
.describe('Exact source-line context containing the occurrence'),
|
|
1869
|
+
expectedTarget: z
|
|
1870
|
+
.string()
|
|
1871
|
+
.optional()
|
|
1872
|
+
.describe('Expected target shape for the migration'),
|
|
1873
|
+
fixture: z
|
|
1874
|
+
.string()
|
|
1875
|
+
.optional()
|
|
1876
|
+
.describe('Fixture or example reference for the migration'),
|
|
1877
|
+
judgment: z
|
|
1878
|
+
.enum(['preserve', 'rewrite', 'unresolved'])
|
|
1879
|
+
.optional()
|
|
1880
|
+
.describe(
|
|
1881
|
+
'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'
|
|
1882
|
+
),
|
|
1883
|
+
matchedForm: z
|
|
1884
|
+
.string()
|
|
1885
|
+
.optional()
|
|
1886
|
+
.describe(
|
|
1887
|
+
'Exact matched source text for the occurrence under review'
|
|
1888
|
+
),
|
|
1889
|
+
nodeKind: z
|
|
1890
|
+
.string()
|
|
1891
|
+
.optional()
|
|
1892
|
+
.describe('AST node kind or source construct kind'),
|
|
1893
|
+
preserveCautions: z
|
|
1894
|
+
.array(z.string())
|
|
1895
|
+
.optional()
|
|
1896
|
+
.describe(
|
|
1897
|
+
'Cautions explaining why a blind rewrite of this occurrence is unsafe'
|
|
1898
|
+
),
|
|
1899
|
+
reason: z.string().describe('Machine-readable review reason'),
|
|
1900
|
+
signals: z
|
|
1901
|
+
.array(z.string())
|
|
1902
|
+
.optional()
|
|
1903
|
+
.describe(
|
|
1904
|
+
'Machine-readable provenance tags for the producing rule or class'
|
|
1905
|
+
),
|
|
1906
|
+
span: z
|
|
1907
|
+
.object({
|
|
1908
|
+
column: z.number().describe('One-based source column'),
|
|
1909
|
+
end: z.number().describe('Source end offset'),
|
|
1910
|
+
line: z.number().describe('One-based source line'),
|
|
1911
|
+
start: z.number().describe('Source start offset'),
|
|
1912
|
+
})
|
|
1913
|
+
.optional()
|
|
1914
|
+
.describe('Source span that needs review'),
|
|
1915
|
+
suggestedValidation: z
|
|
1916
|
+
.string()
|
|
1917
|
+
.optional()
|
|
1918
|
+
.describe('Suggested validation command after resolving review'),
|
|
1919
|
+
symbol: z.string().optional().describe('Symbol or term under review'),
|
|
1920
|
+
})
|
|
1921
|
+
)
|
|
1922
|
+
.optional()
|
|
1923
|
+
.describe('Structured review details from the producing class'),
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
const regradeApplySummarySchema = z.object({
|
|
1927
|
+
applied: z.number().describe('Safe rewrite outcomes written to disk'),
|
|
1928
|
+
filesChanged: z.number().describe('Distinct files changed on disk'),
|
|
1929
|
+
review: z.number().describe('Files still requiring review'),
|
|
1930
|
+
skipped: z.number().describe('Rewrite candidates intentionally not written'),
|
|
1931
|
+
unknown: z.number().describe('Unknown selected class ids'),
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
export const regradeReportOutput = z.object({
|
|
1935
|
+
apply: regradeApplySummarySchema
|
|
1936
|
+
.optional()
|
|
1937
|
+
.describe('Apply-mode summary; absent for dry-run report-only calls'),
|
|
1938
|
+
entries: z
|
|
1939
|
+
.array(regradeReportEntrySchema)
|
|
1940
|
+
.describe(
|
|
1941
|
+
'Per-entry detail, sorted by path. Defaults to actionable rewrite/review entries.'
|
|
1942
|
+
),
|
|
1943
|
+
history: z
|
|
1944
|
+
.object({
|
|
1945
|
+
id: z
|
|
1946
|
+
.string()
|
|
1947
|
+
.optional()
|
|
1948
|
+
.describe('Stable consolidated Regrade history identity'),
|
|
1949
|
+
path: z.string().describe('Root-relative applied history entry path'),
|
|
1950
|
+
provenance: governedVocabularyHistoryProvenanceSchema
|
|
1951
|
+
.optional()
|
|
1952
|
+
.describe('Governed transition evidence attached to the applied run'),
|
|
1953
|
+
schemaVersion: z.number().describe('Regrade history schema version'),
|
|
1954
|
+
status: z
|
|
1955
|
+
.enum(['applied', 'checked', 'replay'])
|
|
1956
|
+
.describe(
|
|
1957
|
+
'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'
|
|
1958
|
+
),
|
|
1959
|
+
})
|
|
1960
|
+
.optional()
|
|
1961
|
+
.describe('Saved applied Regrade history evidence'),
|
|
1962
|
+
matched: z.number().describe('Files with a rewrite or review outcome'),
|
|
1963
|
+
packageSource: z
|
|
1964
|
+
.object({
|
|
1965
|
+
artifactSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
1966
|
+
contentSha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
|
1967
|
+
declaredSpecifier: z.string(),
|
|
1968
|
+
kind: z.enum(['published', 'tarball']),
|
|
1969
|
+
name: z.string().regex(/^@ontrails\/[a-z0-9][a-z0-9._-]*$/u),
|
|
1970
|
+
resolvedPackagePath: z.string(),
|
|
1971
|
+
version: z.string(),
|
|
1972
|
+
})
|
|
1973
|
+
.optional()
|
|
1974
|
+
.describe(
|
|
1975
|
+
'Verified source evidence for one selected downstream Trails package'
|
|
1976
|
+
),
|
|
1977
|
+
plan: z
|
|
1978
|
+
.object({
|
|
1979
|
+
expansionPending: z
|
|
1980
|
+
.number()
|
|
1981
|
+
.optional()
|
|
1982
|
+
.describe('Pending staged expansion candidates on this plan'),
|
|
1983
|
+
path: z.string().describe('Root-relative Regrade plan path'),
|
|
1984
|
+
schemaVersion: z.number().describe('Regrade plan schema version'),
|
|
1985
|
+
status: z
|
|
1986
|
+
.enum(['active', 'stale'])
|
|
1987
|
+
.describe('Whether the saved plan still matches the source tree'),
|
|
1988
|
+
})
|
|
1989
|
+
.optional()
|
|
1990
|
+
.describe('Saved active Regrade plan evidence'),
|
|
1991
|
+
record: z
|
|
1992
|
+
.object({
|
|
1993
|
+
path: z.string().describe('Root-relative transition record path'),
|
|
1994
|
+
schemaVersion: z.number().describe('Transition record schema version'),
|
|
1995
|
+
status: z
|
|
1996
|
+
.enum(['candidate', 'applied', 'checked'])
|
|
1997
|
+
.describe('How this command used the transition record'),
|
|
1998
|
+
})
|
|
1999
|
+
.optional()
|
|
2000
|
+
.describe('Persisted transition record evidence'),
|
|
2001
|
+
review: z.number().describe('Files routed to review'),
|
|
2002
|
+
rewritten: z.number().describe('Files with a rewrite outcome'),
|
|
2003
|
+
root: z.string().describe('Root the run scanned'),
|
|
2004
|
+
run: vocabularyRegradeRunOutput
|
|
2005
|
+
.optional()
|
|
2006
|
+
.describe('Vocabulary regrade run: plan, ledger, and completion report'),
|
|
2007
|
+
scan: regradeScanSummaryOutput.describe(
|
|
2008
|
+
'Agent-facing inventory summary for the scan'
|
|
2009
|
+
),
|
|
2010
|
+
scanned: z.number().describe('Source files inspected'),
|
|
2011
|
+
selectedClassIds: z.array(z.string()).describe('Class ids executed'),
|
|
2012
|
+
skipped: z.number().describe('Entries skipped'),
|
|
2013
|
+
skipsByReason: z
|
|
2014
|
+
.record(z.string(), z.number())
|
|
2015
|
+
.describe('Skipped entries grouped by reason'),
|
|
2016
|
+
unknownClassIds: z
|
|
2017
|
+
.array(z.string())
|
|
2018
|
+
.describe('Selected ids that did not resolve to a class'),
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Built-in regrade classes available to the report trail.
|
|
2023
|
+
*
|
|
2024
|
+
* Warden owns term detection and fix metadata. Regrade derives Warden rules
|
|
2025
|
+
* that advertise `term-rewrite` capability into reportable classes and then
|
|
2026
|
+
* owns application/reporting of the resulting rewrite or review outcomes.
|
|
2027
|
+
*/
|
|
2028
|
+
export const wardenTermRewriteClasses: readonly RegradeClass[] = Object.freeze(
|
|
2029
|
+
[...wardenRules.values()].flatMap((rule) => {
|
|
2030
|
+
const cls = createWardenTermRewriteClass(rule);
|
|
2031
|
+
return cls === null ? [] : [cls];
|
|
2032
|
+
})
|
|
2033
|
+
);
|
|
2034
|
+
|
|
2035
|
+
const duplicateClassDiagnostics = (
|
|
2036
|
+
root: string,
|
|
2037
|
+
classes: readonly RegradeClass[]
|
|
2038
|
+
): readonly WardenDiagnostic[] => {
|
|
2039
|
+
const seen = new Set<string>();
|
|
2040
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
2041
|
+
for (const cls of classes) {
|
|
2042
|
+
if (!seen.has(cls.id)) {
|
|
2043
|
+
seen.add(cls.id);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
diagnostics.push({
|
|
2047
|
+
filePath: root,
|
|
2048
|
+
line: 1,
|
|
2049
|
+
message: `Duplicate Regrade class id "${cls.id}" from Warden term-rewrite rules.`,
|
|
2050
|
+
rule: 'regrade-warden-term-rewrite-classes',
|
|
2051
|
+
severity: 'error',
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
return diagnostics;
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* Load built-in and project-local Warden term-rewrite rules as Regrade classes.
|
|
2059
|
+
*
|
|
2060
|
+
* Built-ins are always available. When `root` is provided, committed
|
|
2061
|
+
* project-local Warden rules under `.trails/rules.ts` or direct
|
|
2062
|
+
* `.trails/rules/*.ts` modules are loaded and any term-rewrite-capable source
|
|
2063
|
+
* rules join the class set.
|
|
2064
|
+
*/
|
|
2065
|
+
export const loadWardenTermRewriteClasses = async (
|
|
2066
|
+
root?: string
|
|
2067
|
+
): Promise<RegradeWardenClassSet> => {
|
|
2068
|
+
if (root === undefined) {
|
|
2069
|
+
return { classes: wardenTermRewriteClasses, diagnostics: [] };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const projectRules = await loadProjectWardenRules(root);
|
|
2073
|
+
const projectClasses = projectRules.sourceRules.flatMap((rule) => {
|
|
2074
|
+
const cls = createWardenTermRewriteClass(rule);
|
|
2075
|
+
return cls === null ? [] : [cls];
|
|
2076
|
+
});
|
|
2077
|
+
const classes = [...wardenTermRewriteClasses, ...projectClasses];
|
|
2078
|
+
return {
|
|
2079
|
+
classes,
|
|
2080
|
+
diagnostics: [
|
|
2081
|
+
...projectRules.diagnostics,
|
|
2082
|
+
...duplicateClassDiagnostics(root, classes),
|
|
2083
|
+
],
|
|
2084
|
+
};
|
|
2085
|
+
};
|