@ontrails/trails 1.0.0-beta.42 → 1.0.0-beta.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/package.json +16 -17
- package/src/mcp-options.ts +1 -0
- package/src/regrade/audit.ts +482 -0
- package/src/regrade/history.ts +250 -42
- package/src/regrade/live-api-preserve.ts +117 -2
- package/src/regrade/plan-artifact.ts +192 -13
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/source-transaction.ts +75 -0
- package/src/release/check.ts +312 -10
- package/src/release/config.ts +10 -0
- package/src/release/index.ts +5 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/run-completions-install.ts +4 -4
- package/src/run-trace.ts +4 -4
- package/src/trails/dev-support.ts +1 -1
- package/src/trails/regrade.ts +802 -125
- package/src/trails/release-check.ts +8 -0
- package/src/trails/run-example.ts +2 -2
- package/src/trails/survey.ts +6 -6
- package/src/trails/topo-output-schemas.ts +8 -8
- package/src/trails/topo-reports.ts +44 -44
- package/src/trails/topo-store-support.ts +2 -4
- package/src/trails/warden-guide.ts +1 -1
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
RegradeReportEntry,
|
|
11
11
|
VocabularyRegradePlan,
|
|
12
12
|
} from '@ontrails/regrade';
|
|
13
|
+
import { getGovernedVocabularyTransition } from '@ontrails/warden';
|
|
13
14
|
import { createHash } from 'node:crypto';
|
|
14
15
|
import { join, normalize, relative } from 'node:path';
|
|
15
16
|
import { z } from 'zod';
|
|
@@ -18,6 +19,108 @@ export const REGRADE_PLAN_SCHEMA_VERSION = 1;
|
|
|
18
19
|
|
|
19
20
|
const regradePlanProvenanceValueSchema = z.enum(['authored', 'derived']);
|
|
20
21
|
|
|
22
|
+
const regradePlanDerivationSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
fileRenames: z.array(
|
|
25
|
+
z
|
|
26
|
+
.object({
|
|
27
|
+
evidence: z.array(z.string()),
|
|
28
|
+
from: z.string(),
|
|
29
|
+
provenance: z.literal('derived'),
|
|
30
|
+
status: z.literal('pending'),
|
|
31
|
+
to: z.string(),
|
|
32
|
+
})
|
|
33
|
+
.strict()
|
|
34
|
+
),
|
|
35
|
+
forms: z.array(
|
|
36
|
+
z
|
|
37
|
+
.object({
|
|
38
|
+
from: z.string(),
|
|
39
|
+
kind: z.enum(['review', 'safe-rewrite']),
|
|
40
|
+
provenance: regradePlanProvenanceValueSchema,
|
|
41
|
+
reason: z.string(),
|
|
42
|
+
source: z.enum([
|
|
43
|
+
'default-morphology',
|
|
44
|
+
'plan-defer',
|
|
45
|
+
'plan-override',
|
|
46
|
+
'seed',
|
|
47
|
+
]),
|
|
48
|
+
to: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
),
|
|
52
|
+
namespaces: z.array(
|
|
53
|
+
z
|
|
54
|
+
.object({
|
|
55
|
+
inScope: z.number().int().nonnegative(),
|
|
56
|
+
namespace: z.string(),
|
|
57
|
+
policyClassified: z.number().int().nonnegative(),
|
|
58
|
+
provenance: z.literal('derived'),
|
|
59
|
+
})
|
|
60
|
+
.strict()
|
|
61
|
+
),
|
|
62
|
+
preserves: z.array(
|
|
63
|
+
z
|
|
64
|
+
.object({
|
|
65
|
+
disposition: z.literal('preserve-current-live-api'),
|
|
66
|
+
evidence: z.array(z.string()),
|
|
67
|
+
pattern: z.string(),
|
|
68
|
+
provenance: z.literal('derived'),
|
|
69
|
+
reason: z.string().optional(),
|
|
70
|
+
})
|
|
71
|
+
.strict()
|
|
72
|
+
),
|
|
73
|
+
referenceClosure: z
|
|
74
|
+
.object({
|
|
75
|
+
entries: z.array(
|
|
76
|
+
z
|
|
77
|
+
.object({
|
|
78
|
+
outcome: z.enum(['needs-review', 'rewrite']),
|
|
79
|
+
path: z.string(),
|
|
80
|
+
provenance: z.literal('derived'),
|
|
81
|
+
reason: z.string().optional(),
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
),
|
|
85
|
+
issue: z.string().optional(),
|
|
86
|
+
moves: z.array(
|
|
87
|
+
z
|
|
88
|
+
.object({
|
|
89
|
+
deferred: z.number().int().nonnegative(),
|
|
90
|
+
from: z.string(),
|
|
91
|
+
historical: z.number().int().nonnegative(),
|
|
92
|
+
preserved: z.number().int().nonnegative(),
|
|
93
|
+
provenance: z.literal('derived'),
|
|
94
|
+
rewritten: z.number().int().nonnegative(),
|
|
95
|
+
skipped: z.number().int().nonnegative(),
|
|
96
|
+
to: z.string(),
|
|
97
|
+
})
|
|
98
|
+
.strict()
|
|
99
|
+
),
|
|
100
|
+
})
|
|
101
|
+
.strict(),
|
|
102
|
+
reviews: z.array(
|
|
103
|
+
z
|
|
104
|
+
.object({
|
|
105
|
+
evidence: z.array(
|
|
106
|
+
z
|
|
107
|
+
.object({
|
|
108
|
+
column: z.number().int().positive().optional(),
|
|
109
|
+
line: z.number().int().positive().optional(),
|
|
110
|
+
path: z.string(),
|
|
111
|
+
})
|
|
112
|
+
.strict()
|
|
113
|
+
),
|
|
114
|
+
provenance: z.literal('derived'),
|
|
115
|
+
reason: z.string(),
|
|
116
|
+
status: z.literal('pending'),
|
|
117
|
+
value: z.string(),
|
|
118
|
+
})
|
|
119
|
+
.strict()
|
|
120
|
+
),
|
|
121
|
+
})
|
|
122
|
+
.strict();
|
|
123
|
+
|
|
21
124
|
const classRegradePlanScopeSchema = z
|
|
22
125
|
.object({
|
|
23
126
|
exclude: z
|
|
@@ -86,6 +189,7 @@ const regradeExpansionCandidateSchema = z.union([
|
|
|
86
189
|
)
|
|
87
190
|
.default([]),
|
|
88
191
|
kind: z.enum(['file-rename', 'form', 'namespace', 'preserve']),
|
|
192
|
+
provenance: regradePlanProvenanceValueSchema.default('derived'),
|
|
89
193
|
reason: z.string().optional(),
|
|
90
194
|
status: z.enum(['pending', 'rejected']).default('pending'),
|
|
91
195
|
suggestedClassification: z.string(),
|
|
@@ -107,6 +211,7 @@ const regradeExpansionCandidateSchema = z.union([
|
|
|
107
211
|
},
|
|
108
212
|
],
|
|
109
213
|
kind: 'file-rename' as const,
|
|
214
|
+
provenance: 'derived' as const,
|
|
110
215
|
...(candidate.detail === undefined ? {} : { reason: candidate.detail }),
|
|
111
216
|
status: candidate.status,
|
|
112
217
|
suggestedClassification: 'legacy-path-candidate',
|
|
@@ -116,6 +221,7 @@ const regradeExpansionCandidateSchema = z.union([
|
|
|
116
221
|
|
|
117
222
|
export const regradePlanArtifactSchema = z
|
|
118
223
|
.object({
|
|
224
|
+
derivation: regradePlanDerivationSchema.optional(),
|
|
119
225
|
expansion: z
|
|
120
226
|
.object({
|
|
121
227
|
candidates: z.array(regradeExpansionCandidateSchema).default([]),
|
|
@@ -148,6 +254,7 @@ export interface RegradePlanExpansion {
|
|
|
148
254
|
readonly path: string;
|
|
149
255
|
}[];
|
|
150
256
|
readonly kind: 'file-rename' | 'form' | 'namespace' | 'preserve';
|
|
257
|
+
readonly provenance: 'authored' | 'derived';
|
|
151
258
|
readonly reason?: string | undefined;
|
|
152
259
|
readonly status: 'pending' | 'rejected';
|
|
153
260
|
readonly suggestedClassification: string;
|
|
@@ -156,6 +263,7 @@ export interface RegradePlanExpansion {
|
|
|
156
263
|
}
|
|
157
264
|
|
|
158
265
|
export interface RegradePlanArtifact {
|
|
266
|
+
readonly derivation?: RegradePlanDerivation | undefined;
|
|
159
267
|
readonly expansion?: RegradePlanExpansion | undefined;
|
|
160
268
|
readonly kind: 'regrade-plan';
|
|
161
269
|
readonly path: string;
|
|
@@ -168,6 +276,10 @@ export interface RegradePlanArtifact {
|
|
|
168
276
|
readonly transitionId?: string | undefined;
|
|
169
277
|
}
|
|
170
278
|
|
|
279
|
+
export type RegradePlanDerivation = z.output<
|
|
280
|
+
typeof regradePlanDerivationSchema
|
|
281
|
+
>;
|
|
282
|
+
|
|
171
283
|
/** A plan artifact narrowed to a vocabulary plan body. */
|
|
172
284
|
export type VocabularyRegradePlanArtifact = RegradePlanArtifact & {
|
|
173
285
|
readonly plan: VocabularyRegradePlan;
|
|
@@ -185,7 +297,15 @@ const regradePlanSlug = (plan: Pick<VocabularyRegradePlan, 'from' | 'to'>) =>
|
|
|
185
297
|
export const regradePlanSlugForBody = (plan: RegradePlanBody): string =>
|
|
186
298
|
plan.kind === 'class'
|
|
187
299
|
? regradeSlugText(plan.name ?? plan.classIds.join('-'))
|
|
188
|
-
:
|
|
300
|
+
: (() => {
|
|
301
|
+
const transition =
|
|
302
|
+
plan.id === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: getGovernedVocabularyTransition(plan.id);
|
|
305
|
+
return transition?.target.kind === 'classified'
|
|
306
|
+
? regradeSlugText(transition.id)
|
|
307
|
+
: regradePlanSlug(plan);
|
|
308
|
+
})();
|
|
189
309
|
|
|
190
310
|
const normalizeRelativePath = (path: string): string =>
|
|
191
311
|
normalize(path).replaceAll('\\', '/');
|
|
@@ -245,9 +365,51 @@ const canonicalizeJsonValue = (value: unknown): unknown => {
|
|
|
245
365
|
export const canonicalJsonStringify = (value: unknown): string =>
|
|
246
366
|
JSON.stringify(canonicalizeJsonValue(value));
|
|
247
367
|
|
|
248
|
-
const
|
|
368
|
+
export const isGeneratedRegradeArtifactPath = (path: string): boolean =>
|
|
369
|
+
/(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
|
|
370
|
+
|
|
371
|
+
const sourceHashLedgerFacts = (
|
|
372
|
+
report: RegradeReport,
|
|
373
|
+
policyMode: 'current' | 'legacy'
|
|
374
|
+
): unknown => {
|
|
375
|
+
const ledger = report.run?.ledger;
|
|
376
|
+
if (ledger === undefined) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
// The active plan is written after its source hash and must not stale itself.
|
|
380
|
+
const occurrences = ledger.occurrences.filter(
|
|
381
|
+
(occurrence) =>
|
|
382
|
+
occurrence.scopeTier !== 'policy-classified' ||
|
|
383
|
+
(policyMode === 'current' &&
|
|
384
|
+
!isGeneratedRegradeArtifactPath(occurrence.path))
|
|
385
|
+
);
|
|
386
|
+
const forms = new Set(occurrences.map((occurrence) => occurrence.form));
|
|
387
|
+
return {
|
|
388
|
+
cycle: ledger.cycle,
|
|
389
|
+
forms: Object.fromEntries(
|
|
390
|
+
Object.entries(ledger.forms).filter(([form]) => forms.has(form))
|
|
391
|
+
),
|
|
392
|
+
occurrences,
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const regradeSourceHashFacts = (
|
|
397
|
+
report: RegradeReport,
|
|
398
|
+
policyMode: 'current' | 'legacy' = 'current',
|
|
399
|
+
fileEvidenceMode: 'current' | 'legacy' = 'current'
|
|
400
|
+
): unknown => ({
|
|
249
401
|
entries: sourceHashEntryFacts(report.entries),
|
|
250
|
-
|
|
402
|
+
fileRenames: report.run?.report.fileRenames?.map(
|
|
403
|
+
({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
|
|
404
|
+
deferred,
|
|
405
|
+
from,
|
|
406
|
+
...(fileEvidenceMode === 'current' ? { historical, preserved } : {}),
|
|
407
|
+
rewritten,
|
|
408
|
+
...(fileEvidenceMode === 'current' ? { skipped } : {}),
|
|
409
|
+
to,
|
|
410
|
+
})
|
|
411
|
+
),
|
|
412
|
+
ledger: sourceHashLedgerFacts(report, policyMode),
|
|
251
413
|
selectedClassIds: report.selectedClassIds,
|
|
252
414
|
});
|
|
253
415
|
|
|
@@ -259,22 +421,39 @@ export const regradeSourceHash = (report: RegradeReport): string =>
|
|
|
259
421
|
canonicalJsonStringify(regradeSourceHashFacts(report))
|
|
260
422
|
);
|
|
261
423
|
|
|
424
|
+
/** Match only the complete current source-evidence shape used by active plans. */
|
|
425
|
+
export const currentRegradeSourceHashMatches = (
|
|
426
|
+
stampedHash: string,
|
|
427
|
+
report: RegradeReport
|
|
428
|
+
): boolean => stampedHash === regradeSourceHash(report);
|
|
429
|
+
|
|
262
430
|
export const legacyRegradeSourceHash = (report: RegradeReport): string =>
|
|
263
431
|
hashSerializedSourceFacts(JSON.stringify(regradeSourceHashFacts(report)));
|
|
264
432
|
|
|
265
|
-
|
|
266
|
-
export const regradeSourceHashMatches = (
|
|
267
|
-
stampedHash: string,
|
|
433
|
+
export const regradeSourceHashes = (
|
|
268
434
|
report: RegradeReport
|
|
269
|
-
):
|
|
270
|
-
|
|
271
|
-
|
|
435
|
+
): readonly string[] => {
|
|
436
|
+
const facts = [
|
|
437
|
+
regradeSourceHashFacts(report),
|
|
438
|
+
regradeSourceHashFacts(report, 'current', 'legacy'),
|
|
439
|
+
regradeSourceHashFacts(report, 'legacy'),
|
|
440
|
+
regradeSourceHashFacts(report, 'legacy', 'legacy'),
|
|
441
|
+
];
|
|
442
|
+
return [
|
|
443
|
+
...new Set(
|
|
444
|
+
facts.flatMap((value) => [
|
|
445
|
+
hashSerializedSourceFacts(canonicalJsonStringify(value)),
|
|
446
|
+
hashSerializedSourceFacts(JSON.stringify(value)),
|
|
447
|
+
])
|
|
448
|
+
),
|
|
449
|
+
];
|
|
450
|
+
};
|
|
272
451
|
|
|
273
|
-
|
|
452
|
+
/** Match source evidence written by any supported historical hash shape. */
|
|
453
|
+
export const regradeSourceHashMatches = (
|
|
454
|
+
stampedHash: string,
|
|
274
455
|
report: RegradeReport
|
|
275
|
-
):
|
|
276
|
-
...new Set([regradeSourceHash(report), legacyRegradeSourceHash(report)]),
|
|
277
|
-
];
|
|
456
|
+
): boolean => regradeSourceHashes(report).includes(stampedHash);
|
|
278
457
|
|
|
279
458
|
/**
|
|
280
459
|
* Canonical content hash of a resolved Regrade plan body — the authored
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAstIdentifierRenameClass,
|
|
3
|
+
deriveFileRenameCandidates,
|
|
4
|
+
deriveVocabularyFormProposals,
|
|
5
|
+
runFileRenameRegrade,
|
|
6
|
+
runRegrade,
|
|
7
|
+
} from '@ontrails/regrade';
|
|
8
|
+
import type {
|
|
9
|
+
RegradeReport,
|
|
10
|
+
VocabularyPreserveInventoryEntry,
|
|
11
|
+
VocabularyRegradePlan,
|
|
12
|
+
} from '@ontrails/regrade';
|
|
13
|
+
|
|
14
|
+
import { isGeneratedRegradeArtifactPath } from './plan-artifact.js';
|
|
15
|
+
import type {
|
|
16
|
+
RegradePlanArtifact,
|
|
17
|
+
RegradePlanDerivation,
|
|
18
|
+
} from './plan-artifact.js';
|
|
19
|
+
|
|
20
|
+
const provenanceForForm = (
|
|
21
|
+
source: RegradePlanDerivation['forms'][number]['source'],
|
|
22
|
+
fields: RegradePlanArtifact['provenance']['fields']
|
|
23
|
+
): 'authored' | 'derived' => {
|
|
24
|
+
if (source === 'seed') {
|
|
25
|
+
return 'authored';
|
|
26
|
+
}
|
|
27
|
+
if (source === 'plan-override') {
|
|
28
|
+
return fields['overrides'] ?? 'derived';
|
|
29
|
+
}
|
|
30
|
+
if (source === 'plan-defer') {
|
|
31
|
+
return fields['deferForms'] ?? 'derived';
|
|
32
|
+
}
|
|
33
|
+
return 'derived';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const namespaceCensus = (
|
|
37
|
+
report: RegradeReport
|
|
38
|
+
): RegradePlanDerivation['namespaces'] => {
|
|
39
|
+
const namespaces = new Map<
|
|
40
|
+
string,
|
|
41
|
+
{ inScope: number; policyClassified: number }
|
|
42
|
+
>();
|
|
43
|
+
for (const occurrence of report.run?.ledger.occurrences ?? []) {
|
|
44
|
+
if (isGeneratedRegradeArtifactPath(occurrence.path)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const namespace = occurrence.path.split('/')[0] ?? occurrence.path;
|
|
48
|
+
const counts = namespaces.get(namespace) ?? {
|
|
49
|
+
inScope: 0,
|
|
50
|
+
policyClassified: 0,
|
|
51
|
+
};
|
|
52
|
+
if (occurrence.scopeTier === 'policy-classified') {
|
|
53
|
+
counts.policyClassified += 1;
|
|
54
|
+
} else {
|
|
55
|
+
counts.inScope += 1;
|
|
56
|
+
}
|
|
57
|
+
namespaces.set(namespace, counts);
|
|
58
|
+
}
|
|
59
|
+
return [...namespaces.entries()]
|
|
60
|
+
.map(([namespace, counts]) => ({
|
|
61
|
+
...counts,
|
|
62
|
+
namespace,
|
|
63
|
+
provenance: 'derived' as const,
|
|
64
|
+
}))
|
|
65
|
+
.toSorted((left, right) => left.namespace.localeCompare(right.namespace));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const observedReviews = (
|
|
69
|
+
report: RegradeReport
|
|
70
|
+
): RegradePlanDerivation['reviews'] => {
|
|
71
|
+
const reviews = new Map<string, RegradePlanDerivation['reviews'][number]>();
|
|
72
|
+
for (const occurrence of report.run?.ledger.occurrences ?? []) {
|
|
73
|
+
if (
|
|
74
|
+
isGeneratedRegradeArtifactPath(occurrence.path) ||
|
|
75
|
+
(occurrence.verdict !== 'deferred' &&
|
|
76
|
+
occurrence.disposition !== 'code-context-out-of-engine' &&
|
|
77
|
+
occurrence.disposition !== 'in-family-unresolved')
|
|
78
|
+
) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const key = `${occurrence.form}\0${occurrence.reason}`;
|
|
82
|
+
const current = reviews.get(key);
|
|
83
|
+
const evidence = {
|
|
84
|
+
column: occurrence.column,
|
|
85
|
+
line: occurrence.line,
|
|
86
|
+
path: occurrence.path,
|
|
87
|
+
};
|
|
88
|
+
reviews.set(key, {
|
|
89
|
+
evidence:
|
|
90
|
+
current === undefined ? [evidence] : [...current.evidence, evidence],
|
|
91
|
+
provenance: 'derived',
|
|
92
|
+
reason: occurrence.reason,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
value: occurrence.form,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return [...reviews.values()]
|
|
98
|
+
.map((review) => ({
|
|
99
|
+
...review,
|
|
100
|
+
evidence: review.evidence.toSorted((left, right) =>
|
|
101
|
+
left.path === right.path
|
|
102
|
+
? (left.line ?? 0) - (right.line ?? 0)
|
|
103
|
+
: left.path.localeCompare(right.path)
|
|
104
|
+
),
|
|
105
|
+
}))
|
|
106
|
+
.toSorted((left, right) => left.value.localeCompare(right.value));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const identifierReviews = (params: {
|
|
110
|
+
readonly plan: VocabularyRegradePlan;
|
|
111
|
+
readonly rootDir: string;
|
|
112
|
+
}): RegradePlanDerivation['reviews'] => {
|
|
113
|
+
const { scope } = params.plan;
|
|
114
|
+
const sourceForms = [
|
|
115
|
+
...new Map(
|
|
116
|
+
deriveVocabularyFormProposals(params.plan).map((form) => [
|
|
117
|
+
form.from,
|
|
118
|
+
form,
|
|
119
|
+
])
|
|
120
|
+
).values(),
|
|
121
|
+
];
|
|
122
|
+
const collection = {
|
|
123
|
+
...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
|
|
124
|
+
extensions: scope?.extensions ?? ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
|
|
125
|
+
...(scope?.include === undefined ? {} : { include: scope.include }),
|
|
126
|
+
};
|
|
127
|
+
const entries: RegradeReport['entries'][number][] = [];
|
|
128
|
+
for (const form of sourceForms) {
|
|
129
|
+
const report = runRegrade({
|
|
130
|
+
classes: [
|
|
131
|
+
createAstIdentifierRenameClass({
|
|
132
|
+
from: form.from,
|
|
133
|
+
id: `derived-plan-identifiers:${form.from}->${form.to ?? params.plan.to}`,
|
|
134
|
+
match: 'identifier-segment',
|
|
135
|
+
reviewAllMatches: true,
|
|
136
|
+
to: form.to ?? params.plan.to,
|
|
137
|
+
}),
|
|
138
|
+
],
|
|
139
|
+
collection,
|
|
140
|
+
root: params.rootDir,
|
|
141
|
+
});
|
|
142
|
+
if (report.isErr()) {
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
evidence: [],
|
|
146
|
+
provenance: 'derived',
|
|
147
|
+
reason: `identifier-review-scan-failed: ${report.error.message}`,
|
|
148
|
+
status: 'pending',
|
|
149
|
+
value: form.from,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
entries.push(...(report.value?.entries ?? []));
|
|
154
|
+
}
|
|
155
|
+
return entries
|
|
156
|
+
.flatMap((entry) =>
|
|
157
|
+
(entry.reviewDetails ?? []).flatMap((detail) =>
|
|
158
|
+
detail.symbol === undefined
|
|
159
|
+
? []
|
|
160
|
+
: [
|
|
161
|
+
{
|
|
162
|
+
evidence: [
|
|
163
|
+
{
|
|
164
|
+
...(detail.span?.column === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { column: detail.span.column }),
|
|
167
|
+
...(detail.span?.line === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: { line: detail.span.line }),
|
|
170
|
+
path: entry.path,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
provenance: 'derived' as const,
|
|
174
|
+
reason: detail.reason,
|
|
175
|
+
status: 'pending' as const,
|
|
176
|
+
value: detail.symbol,
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
.toSorted((left, right) => left.value.localeCompare(right.value));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const mergeReviews = (
|
|
185
|
+
...groups: readonly RegradePlanDerivation['reviews'][]
|
|
186
|
+
): RegradePlanDerivation['reviews'] => {
|
|
187
|
+
const merged = new Map<string, RegradePlanDerivation['reviews'][number]>();
|
|
188
|
+
for (const review of groups.flat()) {
|
|
189
|
+
const key = `${review.value}\0${review.reason}`;
|
|
190
|
+
const current = merged.get(key);
|
|
191
|
+
merged.set(key, {
|
|
192
|
+
...review,
|
|
193
|
+
evidence: [...(current?.evidence ?? []), ...review.evidence]
|
|
194
|
+
.filter(
|
|
195
|
+
(entry, index, evidence) =>
|
|
196
|
+
evidence.findIndex(
|
|
197
|
+
(candidate) =>
|
|
198
|
+
candidate.path === entry.path &&
|
|
199
|
+
candidate.line === entry.line &&
|
|
200
|
+
candidate.column === entry.column
|
|
201
|
+
) === index
|
|
202
|
+
)
|
|
203
|
+
.toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return [...merged.values()].toSorted((left, right) =>
|
|
207
|
+
left.value === right.value
|
|
208
|
+
? left.reason.localeCompare(right.reason)
|
|
209
|
+
: left.value.localeCompare(right.value)
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const referenceClosure = (params: {
|
|
214
|
+
readonly candidates: ReturnType<typeof deriveFileRenameCandidates>;
|
|
215
|
+
readonly plan: VocabularyRegradePlan;
|
|
216
|
+
readonly rootDir: string;
|
|
217
|
+
}): RegradePlanDerivation['referenceClosure'] => {
|
|
218
|
+
if (params.candidates.length === 0) {
|
|
219
|
+
return { entries: [], moves: [] };
|
|
220
|
+
}
|
|
221
|
+
const result = runFileRenameRegrade({
|
|
222
|
+
excludeGeneratedArtifacts: true,
|
|
223
|
+
renames: params.candidates,
|
|
224
|
+
root: params.rootDir,
|
|
225
|
+
...(params.plan.scope === undefined ? {} : { scope: params.plan.scope }),
|
|
226
|
+
});
|
|
227
|
+
if (result.isErr()) {
|
|
228
|
+
return { entries: [], issue: result.error.message, moves: [] };
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
entries: result.value.report.entries
|
|
232
|
+
.filter(
|
|
233
|
+
(
|
|
234
|
+
entry
|
|
235
|
+
): entry is typeof entry & {
|
|
236
|
+
readonly outcome: 'needs-review' | 'rewrite';
|
|
237
|
+
} => entry.outcome === 'needs-review' || entry.outcome === 'rewrite'
|
|
238
|
+
)
|
|
239
|
+
.map((entry) => ({
|
|
240
|
+
outcome: entry.outcome,
|
|
241
|
+
path: entry.path,
|
|
242
|
+
provenance: 'derived' as const,
|
|
243
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
244
|
+
}))
|
|
245
|
+
.toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
246
|
+
moves: result.value.evidence.map(
|
|
247
|
+
({ deferred, from, historical, preserved, rewritten, skipped, to }) => ({
|
|
248
|
+
deferred,
|
|
249
|
+
from,
|
|
250
|
+
historical,
|
|
251
|
+
preserved,
|
|
252
|
+
provenance: 'derived' as const,
|
|
253
|
+
rewritten,
|
|
254
|
+
skipped,
|
|
255
|
+
to,
|
|
256
|
+
})
|
|
257
|
+
),
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const deriveRegradePlanDerivation = (params: {
|
|
262
|
+
readonly plan: VocabularyRegradePlan;
|
|
263
|
+
readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
|
|
264
|
+
readonly provenance: RegradePlanArtifact['provenance'];
|
|
265
|
+
readonly report: RegradeReport;
|
|
266
|
+
readonly rootDir: string;
|
|
267
|
+
}): RegradePlanDerivation => {
|
|
268
|
+
const candidates = deriveFileRenameCandidates({
|
|
269
|
+
plan: params.plan,
|
|
270
|
+
root: params.rootDir,
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
fileRenames: candidates.map((candidate) => ({
|
|
274
|
+
...candidate,
|
|
275
|
+
evidence: [...candidate.evidence],
|
|
276
|
+
provenance: 'derived' as const,
|
|
277
|
+
status: 'pending' as const,
|
|
278
|
+
})),
|
|
279
|
+
forms: deriveVocabularyFormProposals(params.plan).map((form) => ({
|
|
280
|
+
...form,
|
|
281
|
+
provenance: provenanceForForm(form.source, params.provenance.fields),
|
|
282
|
+
})),
|
|
283
|
+
namespaces: namespaceCensus(params.report),
|
|
284
|
+
preserves: params.preserveInventory.map((entry) => ({
|
|
285
|
+
disposition: 'preserve-current-live-api' as const,
|
|
286
|
+
evidence: [...entry.evidence],
|
|
287
|
+
pattern: entry.pattern,
|
|
288
|
+
provenance: 'derived' as const,
|
|
289
|
+
...(entry.reason === undefined ? {} : { reason: entry.reason }),
|
|
290
|
+
})),
|
|
291
|
+
referenceClosure: referenceClosure({
|
|
292
|
+
candidates,
|
|
293
|
+
plan: params.plan,
|
|
294
|
+
rootDir: params.rootDir,
|
|
295
|
+
}),
|
|
296
|
+
reviews: mergeReviews(
|
|
297
|
+
observedReviews(params.report),
|
|
298
|
+
identifierReviews({ plan: params.plan, rootDir: params.rootDir })
|
|
299
|
+
),
|
|
300
|
+
};
|
|
301
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { InternalError, Result, deriveSafePath } from '@ontrails/core';
|
|
2
|
+
import type { Result as TrailsResult } from '@ontrails/core';
|
|
3
|
+
import type { RegradeReport } from '@ontrails/regrade';
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export interface RegradeSourceSnapshot {
|
|
7
|
+
readonly absolutePath: string;
|
|
8
|
+
readonly source: Uint8Array;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const snapshotRegradeSources = (params: {
|
|
12
|
+
readonly reports: readonly (RegradeReport | null)[];
|
|
13
|
+
readonly rootDir: string;
|
|
14
|
+
}): TrailsResult<readonly RegradeSourceSnapshot[], Error> => {
|
|
15
|
+
const paths = new Set(
|
|
16
|
+
params.reports.flatMap(
|
|
17
|
+
(report) =>
|
|
18
|
+
report?.entries
|
|
19
|
+
.filter(
|
|
20
|
+
(entry) =>
|
|
21
|
+
entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
22
|
+
)
|
|
23
|
+
.map((entry) => entry.path) ?? []
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
const snapshots: RegradeSourceSnapshot[] = [];
|
|
27
|
+
try {
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
const absolutePath = deriveSafePath(params.rootDir, path);
|
|
30
|
+
if (absolutePath.isErr()) {
|
|
31
|
+
return absolutePath;
|
|
32
|
+
}
|
|
33
|
+
snapshots.push({
|
|
34
|
+
absolutePath: absolutePath.value,
|
|
35
|
+
source: readFileSync(absolutePath.value),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return Result.err(
|
|
40
|
+
new InternalError(
|
|
41
|
+
'Failed to snapshot Regrade sources before apply.',
|
|
42
|
+
error instanceof Error ? { cause: error } : {}
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return Result.ok(snapshots);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const rollbackRegradeSources = (
|
|
50
|
+
snapshots: readonly RegradeSourceSnapshot[]
|
|
51
|
+
): Error | null => {
|
|
52
|
+
try {
|
|
53
|
+
for (const snapshot of snapshots) {
|
|
54
|
+
writeFileSync(snapshot.absolutePath, snapshot.source);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const regradeApplyErrorAfterRollback = (
|
|
63
|
+
error: Error,
|
|
64
|
+
snapshots: readonly RegradeSourceSnapshot[]
|
|
65
|
+
): TrailsResult<never, Error> => {
|
|
66
|
+
const rollbackError = rollbackRegradeSources(snapshots);
|
|
67
|
+
return rollbackError === null
|
|
68
|
+
? Result.err(error)
|
|
69
|
+
: Result.err(
|
|
70
|
+
new InternalError('Regrade apply failed and source rollback failed.', {
|
|
71
|
+
cause: error,
|
|
72
|
+
context: { rollbackError: rollbackError.message },
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
};
|