@ontrails/trails 1.0.0-beta.45 → 1.0.0-beta.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/package.json +16 -16
- package/src/cli.ts +13 -0
- package/src/regrade/audit.ts +122 -33
- package/src/regrade/history.ts +423 -425
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/plan-artifact.ts +0 -3
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +445 -0
- package/src/regrade/source-transaction.ts +121 -11
- package/src/run-regrade-progress.ts +47 -0
- package/src/trails/create-scaffold.ts +1 -0
- package/src/trails/regrade.ts +965 -236
package/src/regrade/history.ts
CHANGED
|
@@ -7,103 +7,300 @@
|
|
|
7
7
|
|
|
8
8
|
import { InternalError, Result, ValidationError } from '@ontrails/core';
|
|
9
9
|
import type { Result as TrailsResult } from '@ontrails/core';
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
10
|
+
import { resolveRegradeHistoryReceipt } from '@ontrails/regrade';
|
|
11
|
+
import type {
|
|
12
|
+
RegradeFormJudgment,
|
|
13
|
+
RegradeReport,
|
|
14
|
+
ResolvedRegradeHistoryReceipt,
|
|
15
|
+
} from '@ontrails/regrade';
|
|
12
16
|
import {
|
|
13
17
|
getGovernedVocabularyTransition,
|
|
14
|
-
governedVocabularyHistoryProvenanceSchema,
|
|
15
18
|
listGovernedVocabularyTransitions,
|
|
16
19
|
} from '@ontrails/warden';
|
|
17
20
|
import type { GovernedVocabularyHistoryProvenance } from '@ontrails/warden';
|
|
18
|
-
import { createHash } from 'node:crypto';
|
|
19
|
-
import {
|
|
21
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
20
31
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
21
|
-
import { z } from 'zod';
|
|
22
32
|
|
|
23
33
|
import {
|
|
24
|
-
regradePlanArtifactSchema,
|
|
25
34
|
regradePlanContentHash,
|
|
26
35
|
regradePlanDirectory,
|
|
27
36
|
regradePlanSlugForBody,
|
|
28
|
-
legacyRegradeSourceHash,
|
|
29
37
|
regradeSourceHash,
|
|
30
|
-
regradeSourceHashMatches,
|
|
31
|
-
regradeSourceHashes,
|
|
32
38
|
rootRelativePath,
|
|
33
39
|
} from './plan-artifact.js';
|
|
34
40
|
import type { RegradePlanArtifact, RegradePlanBody } from './plan-artifact.js';
|
|
41
|
+
import {
|
|
42
|
+
buildRegradeHistoryReceipt,
|
|
43
|
+
resolveRegradeSourceRevision,
|
|
44
|
+
serializeRegradeHistoryReceipt,
|
|
45
|
+
} from './receipt-history.js';
|
|
46
|
+
import type { RegradeChangedFileEvidence } from './receipt-history.js';
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
* Consolidated history schema version. Version 1 was the retired
|
|
38
|
-
* one-file-per-run shape whose filename carried the lock hash.
|
|
39
|
-
*/
|
|
40
|
-
export const REGRADE_HISTORY_SCHEMA_VERSION = 2;
|
|
41
|
-
|
|
42
|
-
const rawCompletionReportHash = Symbol('rawCompletionReportHash');
|
|
43
|
-
const rawReportHash = Symbol('rawReportHash');
|
|
44
|
-
const rawCompletionReport = Symbol('rawCompletionReport');
|
|
45
|
-
const rawReport = Symbol('rawReport');
|
|
46
|
-
|
|
47
|
-
const regradeHistoryRunSchema = z
|
|
48
|
-
.object({
|
|
49
|
-
completionReport: regradeReportOutput
|
|
50
|
-
.optional()
|
|
51
|
-
.describe(
|
|
52
|
-
'Post-apply source evidence used to recognize a completed-state replay'
|
|
53
|
-
),
|
|
54
|
-
completionReportHash: z
|
|
55
|
-
.string()
|
|
56
|
-
.regex(/^[0-9a-f]{64}$/)
|
|
57
|
-
.optional()
|
|
58
|
-
.describe('Canonical hash of the post-apply completion report'),
|
|
59
|
-
lockHashAtRun: z
|
|
60
|
-
.string()
|
|
61
|
-
.min(1)
|
|
62
|
-
.describe('Regrade source hash observed when this run applied'),
|
|
63
|
-
plan: regradePlanArtifactSchema.describe(
|
|
64
|
-
'Plan artifact consumed by this run'
|
|
65
|
-
),
|
|
66
|
-
planContentHash: z
|
|
67
|
-
.string()
|
|
68
|
-
.min(1)
|
|
69
|
-
.describe('Canonical content hash of the resolved plan body'),
|
|
70
|
-
provenance: governedVocabularyHistoryProvenanceSchema.optional(),
|
|
71
|
-
report: regradeReportOutput.describe('Applied report recorded by the run'),
|
|
72
|
-
})
|
|
73
|
-
.strict();
|
|
48
|
+
const resolvedReceipt = Symbol('resolvedReceipt');
|
|
74
49
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
50
|
+
export const writeRegradeHistoryFileAtomically = (params: {
|
|
51
|
+
readonly absolutePath: string;
|
|
52
|
+
readonly content: string;
|
|
53
|
+
readonly diagnosticPath: string;
|
|
54
|
+
readonly replace?: typeof renameSync | undefined;
|
|
55
|
+
}): TrailsResult<void, InternalError> => {
|
|
56
|
+
const temporaryPath = join(
|
|
57
|
+
dirname(params.absolutePath),
|
|
58
|
+
`.${randomUUID()}.regrade-history.tmp`
|
|
59
|
+
);
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dirname(params.absolutePath), { recursive: true });
|
|
62
|
+
writeFileSync(temporaryPath, params.content);
|
|
63
|
+
(params.replace ?? renameSync)(temporaryPath, params.absolutePath);
|
|
64
|
+
return Result.ok();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
try {
|
|
67
|
+
rmSync(temporaryPath, { force: true });
|
|
68
|
+
} catch {
|
|
69
|
+
// Preserve the primary persistence failure.
|
|
70
|
+
}
|
|
71
|
+
return Result.err(
|
|
72
|
+
new InternalError('Failed to atomically write Regrade history.', {
|
|
73
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
74
|
+
context: { path: params.diagnosticPath },
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
84
79
|
|
|
80
|
+
export const consumeActiveRegradePlanAfterHistoryWrite = (params: {
|
|
81
|
+
readonly absoluteHistoryPath: string;
|
|
82
|
+
readonly absolutePlanPath: string;
|
|
83
|
+
readonly historyPath: string;
|
|
84
|
+
readonly planPath: string;
|
|
85
|
+
readonly priorHistoryBytes?: string | undefined;
|
|
86
|
+
readonly remove?: ((path: string) => void) | undefined;
|
|
87
|
+
readonly replace?: typeof renameSync | undefined;
|
|
88
|
+
}): TrailsResult<void, InternalError> => {
|
|
89
|
+
const remove =
|
|
90
|
+
params.remove ?? ((path: string) => rmSync(path, { force: true }));
|
|
91
|
+
const removeNewHistory = (): Error | undefined => {
|
|
92
|
+
try {
|
|
93
|
+
remove(params.absoluteHistoryPath);
|
|
94
|
+
return undefined;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
remove(params.absolutePlanPath);
|
|
101
|
+
return Result.ok();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
let rollbackError: Error | undefined;
|
|
104
|
+
if (params.priorHistoryBytes === undefined) {
|
|
105
|
+
rollbackError = removeNewHistory();
|
|
106
|
+
} else {
|
|
107
|
+
const restored = writeRegradeHistoryFileAtomically({
|
|
108
|
+
absolutePath: params.absoluteHistoryPath,
|
|
109
|
+
content: params.priorHistoryBytes,
|
|
110
|
+
diagnosticPath: params.historyPath,
|
|
111
|
+
replace: params.replace,
|
|
112
|
+
});
|
|
113
|
+
if (restored.isErr()) {
|
|
114
|
+
rollbackError = restored.error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Result.err(
|
|
118
|
+
new InternalError('Failed to remove active Regrade plan.', {
|
|
119
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
120
|
+
context: {
|
|
121
|
+
history: params.historyPath,
|
|
122
|
+
plan: params.planPath,
|
|
123
|
+
...(rollbackError === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { historyRollback: rollbackError.message }),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
85
131
|
interface RegradeHistoryRun {
|
|
86
|
-
readonly [rawCompletionReportHash]?: string;
|
|
87
|
-
readonly [rawCompletionReport]?: RegradeReport;
|
|
88
|
-
readonly [rawReportHash]?: string;
|
|
89
|
-
readonly [rawReport]?: RegradeReport;
|
|
90
132
|
readonly completionReport: RegradeReport;
|
|
91
133
|
readonly completionReportHash: string;
|
|
92
134
|
readonly lockHashAtRun: string;
|
|
93
135
|
readonly plan: RegradePlanArtifact;
|
|
94
136
|
readonly planContentHash: string;
|
|
95
|
-
readonly provenance?: GovernedVocabularyHistoryProvenance;
|
|
96
137
|
readonly report: RegradeReport;
|
|
97
138
|
}
|
|
98
139
|
|
|
99
140
|
export interface RegradeHistoryArtifact {
|
|
141
|
+
readonly [resolvedReceipt]?: ResolvedRegradeHistoryReceipt;
|
|
100
142
|
readonly id: string;
|
|
101
143
|
readonly kind: 'regrade-history';
|
|
102
144
|
readonly path: string;
|
|
103
145
|
readonly runs: readonly RegradeHistoryRun[];
|
|
104
|
-
readonly schemaVersion:
|
|
146
|
+
readonly schemaVersion: number;
|
|
105
147
|
}
|
|
106
148
|
|
|
149
|
+
const receiptDisposition = (
|
|
150
|
+
disposition: RegradeFormJudgment['disposition']
|
|
151
|
+
) => {
|
|
152
|
+
switch (disposition) {
|
|
153
|
+
case 'mapped': {
|
|
154
|
+
return {
|
|
155
|
+
disposition: 'in-family-modified' as const,
|
|
156
|
+
verdict: 'applied' as const,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
case 'out-of-family': {
|
|
160
|
+
return {
|
|
161
|
+
disposition: 'out-of-family' as const,
|
|
162
|
+
verdict: 'skipped' as const,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
case 'preserved': {
|
|
166
|
+
return {
|
|
167
|
+
disposition: 'explicit-preserve' as const,
|
|
168
|
+
verdict: 'skipped' as const,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
case 'unresolved': {
|
|
172
|
+
return {
|
|
173
|
+
disposition: 'in-family-unresolved' as const,
|
|
174
|
+
verdict: 'deferred' as const,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
default: {
|
|
178
|
+
const exhaustive: never = disposition;
|
|
179
|
+
return exhaustive;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const reportForReceiptRun = (
|
|
185
|
+
run: ResolvedRegradeHistoryReceipt['runs'][number]
|
|
186
|
+
): RegradeReport => {
|
|
187
|
+
const plan = run.plan as RegradePlanBody;
|
|
188
|
+
const { completion } = run.receipt;
|
|
189
|
+
const entries = run.receipt.evidence.changedFiles.map((file) => ({
|
|
190
|
+
outcome: 'rewrite' as const,
|
|
191
|
+
path: file.afterPath,
|
|
192
|
+
}));
|
|
193
|
+
const skipped = Object.values(completion.counts.skippedByReason).reduce(
|
|
194
|
+
(sum, count) => sum + count,
|
|
195
|
+
0
|
|
196
|
+
);
|
|
197
|
+
const base: RegradeReport = {
|
|
198
|
+
apply: {
|
|
199
|
+
applied: completion.counts.rewritten,
|
|
200
|
+
filesChanged: completion.metrics.filesChanged,
|
|
201
|
+
review: completion.counts.review,
|
|
202
|
+
skipped,
|
|
203
|
+
unknown: completion.counts.unknown,
|
|
204
|
+
},
|
|
205
|
+
entries,
|
|
206
|
+
matched: completion.counts.matched,
|
|
207
|
+
review: completion.counts.review,
|
|
208
|
+
rewritten: completion.counts.rewritten,
|
|
209
|
+
root: '.',
|
|
210
|
+
scan: {
|
|
211
|
+
byDirectory: [],
|
|
212
|
+
byExtension: [],
|
|
213
|
+
files: {
|
|
214
|
+
matched: completion.counts.matched,
|
|
215
|
+
scanned: 0,
|
|
216
|
+
skipped,
|
|
217
|
+
},
|
|
218
|
+
skippedByReason: completion.counts.skippedByReason,
|
|
219
|
+
},
|
|
220
|
+
scanned: 0,
|
|
221
|
+
selectedClassIds:
|
|
222
|
+
plan.kind === 'class'
|
|
223
|
+
? plan.classIds
|
|
224
|
+
: [plan.id ?? `vocabulary:${plan.from}->${plan.to}`],
|
|
225
|
+
skipped,
|
|
226
|
+
skipsByReason: completion.counts.skippedByReason,
|
|
227
|
+
unknownClassIds: [],
|
|
228
|
+
};
|
|
229
|
+
if (plan.kind !== 'vocabulary') {
|
|
230
|
+
return base;
|
|
231
|
+
}
|
|
232
|
+
const occurrences = run.classifiedState.forms.map((form, index) => {
|
|
233
|
+
const mapped = receiptDisposition(form.disposition);
|
|
234
|
+
return {
|
|
235
|
+
column: 1,
|
|
236
|
+
context: '',
|
|
237
|
+
...mapped,
|
|
238
|
+
end: 0,
|
|
239
|
+
form: form.form,
|
|
240
|
+
line: form.representative?.line ?? 1,
|
|
241
|
+
path: form.representative?.path ?? run.receipt.transitionId,
|
|
242
|
+
reason: form.reason ?? form.disposition,
|
|
243
|
+
...(form.target === undefined ? {} : { replacement: form.target }),
|
|
244
|
+
scopeTier: 'in-scope' as const,
|
|
245
|
+
start: index,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
...base,
|
|
250
|
+
run: {
|
|
251
|
+
ledger: {
|
|
252
|
+
cycle: 1,
|
|
253
|
+
forms: Object.fromEntries(
|
|
254
|
+
occurrences.map((occurrence) => [occurrence.form, occurrence.verdict])
|
|
255
|
+
),
|
|
256
|
+
occurrences,
|
|
257
|
+
},
|
|
258
|
+
plan,
|
|
259
|
+
report: {
|
|
260
|
+
applied: completion.counts.rewritten,
|
|
261
|
+
deferred: completion.counts.review,
|
|
262
|
+
dispositions: completion.counts.dispositions,
|
|
263
|
+
filesChanged: completion.metrics.filesChanged,
|
|
264
|
+
gate: {
|
|
265
|
+
...completion.gate,
|
|
266
|
+
remainingByDisposition: {},
|
|
267
|
+
},
|
|
268
|
+
modified: completion.counts.rewritten,
|
|
269
|
+
open: completion.gate.remaining,
|
|
270
|
+
scopeTiers: { 'in-scope': occurrences.length, 'policy-classified': 0 },
|
|
271
|
+
skipped,
|
|
272
|
+
teachingSurfaces: { expected: [], missing: [], touched: [] },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const projectReceiptHistory = (
|
|
279
|
+
receipt: ResolvedRegradeHistoryReceipt
|
|
280
|
+
): RegradeHistoryArtifact => ({
|
|
281
|
+
[resolvedReceipt]: receipt,
|
|
282
|
+
id: receipt.artifact.id,
|
|
283
|
+
kind: 'regrade-history',
|
|
284
|
+
path: receipt.artifact.path,
|
|
285
|
+
runs: receipt.runs.map((run) => ({
|
|
286
|
+
completionReport: reportForReceiptRun(run),
|
|
287
|
+
completionReportHash: run.receipt.evidence.sourceStateHash,
|
|
288
|
+
lockHashAtRun: run.receipt.evidence.lockStateHash,
|
|
289
|
+
plan: {
|
|
290
|
+
kind: 'regrade-plan',
|
|
291
|
+
path: `.trails/regrade/plans/${receipt.artifact.path.split('/').at(-1) ?? 'receipt'}`,
|
|
292
|
+
plan: run.plan as RegradePlanBody,
|
|
293
|
+
provenance: run.provenance,
|
|
294
|
+
schemaVersion: 1,
|
|
295
|
+
sourceHash: run.receipt.evidence.sourceStateHash,
|
|
296
|
+
transitionId: receipt.artifact.id,
|
|
297
|
+
},
|
|
298
|
+
planContentHash: run.receipt.intent.planContentHash,
|
|
299
|
+
report: reportForReceiptRun(run),
|
|
300
|
+
})),
|
|
301
|
+
schemaVersion: receipt.artifact.schemaVersion,
|
|
302
|
+
});
|
|
303
|
+
|
|
107
304
|
export interface RegradeHistorySummary {
|
|
108
305
|
readonly id: string;
|
|
109
306
|
readonly path: string;
|
|
@@ -141,54 +338,26 @@ export const readRegradeHistoryArtifact = (
|
|
|
141
338
|
})
|
|
142
339
|
);
|
|
143
340
|
}
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
341
|
+
const receipt = resolveRegradeHistoryReceipt(parsedJson);
|
|
342
|
+
if (receipt.isErr()) {
|
|
343
|
+
return receipt;
|
|
344
|
+
}
|
|
345
|
+
const observedPath = path.replaceAll('\\', '/');
|
|
346
|
+
const embeddedPath = receipt.value.artifact.path;
|
|
347
|
+
if (
|
|
348
|
+
observedPath !== embeddedPath &&
|
|
349
|
+
!observedPath.endsWith(`/${embeddedPath}`)
|
|
350
|
+
) {
|
|
146
351
|
return Result.err(
|
|
147
|
-
new ValidationError(
|
|
148
|
-
|
|
149
|
-
|
|
352
|
+
new ValidationError(
|
|
353
|
+
'Regrade history path does not match its observed file.',
|
|
354
|
+
{
|
|
355
|
+
context: { embeddedPath, observedPath },
|
|
356
|
+
}
|
|
357
|
+
)
|
|
150
358
|
);
|
|
151
359
|
}
|
|
152
|
-
|
|
153
|
-
parsedJson as {
|
|
154
|
-
readonly runs: readonly {
|
|
155
|
-
readonly completionReport?: RegradeReport;
|
|
156
|
-
readonly report: RegradeReport;
|
|
157
|
-
}[];
|
|
158
|
-
}
|
|
159
|
-
).runs;
|
|
160
|
-
return Result.ok({
|
|
161
|
-
...parsed.data,
|
|
162
|
-
runs: parsed.data.runs.map((run, index) => {
|
|
163
|
-
// Early schema-v2 histories recorded only the post-apply report. Treat
|
|
164
|
-
// that report and its lock hash as completion evidence when reading
|
|
165
|
-
// those artifacts.
|
|
166
|
-
const completionReport = run.completionReport ?? run.report;
|
|
167
|
-
const normalized = {
|
|
168
|
-
...run,
|
|
169
|
-
completionReport,
|
|
170
|
-
completionReportHash: run.completionReportHash ?? run.lockHashAtRun,
|
|
171
|
-
} as RegradeHistoryRun;
|
|
172
|
-
const rawRun = rawRuns[index];
|
|
173
|
-
if (rawRun !== undefined) {
|
|
174
|
-
Object.defineProperties(normalized, {
|
|
175
|
-
[rawCompletionReport]: {
|
|
176
|
-
value: rawRun.completionReport ?? rawRun.report,
|
|
177
|
-
},
|
|
178
|
-
[rawCompletionReportHash]: {
|
|
179
|
-
value: legacyRegradeSourceHash(
|
|
180
|
-
rawRun.completionReport ?? rawRun.report
|
|
181
|
-
),
|
|
182
|
-
},
|
|
183
|
-
[rawReportHash]: {
|
|
184
|
-
value: legacyRegradeSourceHash(rawRun.report),
|
|
185
|
-
},
|
|
186
|
-
[rawReport]: { value: rawRun.report },
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
return normalized;
|
|
190
|
-
}),
|
|
191
|
-
} as RegradeHistoryArtifact);
|
|
360
|
+
return Result.ok(projectReceiptHistory(receipt.value));
|
|
192
361
|
};
|
|
193
362
|
|
|
194
363
|
/**
|
|
@@ -258,237 +427,120 @@ export const validateGovernedRegradePlan = (
|
|
|
258
427
|
);
|
|
259
428
|
};
|
|
260
429
|
|
|
261
|
-
|
|
430
|
+
/** Receipts are validated and hash-resolved during read. */
|
|
431
|
+
export const verifyRegradeHistoryRuns = (
|
|
432
|
+
artifact: RegradeHistoryArtifact
|
|
433
|
+
): TrailsResult<{ readonly runs: number }, ValidationError> =>
|
|
434
|
+
Result.ok({ runs: artifact.runs.length });
|
|
435
|
+
|
|
436
|
+
const appendReceiptHistoryRun = (params: {
|
|
437
|
+
readonly absolutePath: string;
|
|
262
438
|
readonly artifact: RegradePlanArtifact;
|
|
263
|
-
readonly
|
|
439
|
+
readonly changedFiles: readonly RegradeChangedFileEvidence[];
|
|
440
|
+
readonly completedReport: RegradeReport;
|
|
441
|
+
readonly current: RegradeHistoryArtifact | undefined;
|
|
442
|
+
readonly lockHashAtRun: string;
|
|
264
443
|
readonly planContentHash: string;
|
|
444
|
+
readonly relativePath: string;
|
|
265
445
|
readonly report: RegradeReport;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const transition = governedTransitionForPlan(plan);
|
|
279
|
-
if (transition === undefined) {
|
|
280
|
-
return Result.ok();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const reviewPending = params.report.review;
|
|
284
|
-
return Result.ok({
|
|
285
|
-
disposition: reviewPending > 0 ? 'review-follow-up' : 'applied-clean',
|
|
286
|
-
kind: 'governed-vocabulary',
|
|
287
|
-
planContentHash: params.planContentHash,
|
|
288
|
-
reviewPending,
|
|
289
|
-
safeApplied: params.report.apply?.applied ?? params.report.rewritten,
|
|
290
|
-
sourceHashAfter: regradeSourceHash(params.completionReport),
|
|
291
|
-
sourceHashBefore: regradeSourceHash(params.report),
|
|
292
|
-
transitionId: transition.id,
|
|
293
|
-
});
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const governedProvenanceMatchesRun = (params: {
|
|
297
|
-
readonly expected: GovernedVocabularyHistoryProvenance | undefined;
|
|
298
|
-
readonly run: RegradeHistoryRun;
|
|
299
|
-
}): boolean => {
|
|
300
|
-
const { expected, run } = params;
|
|
301
|
-
const actual = run.provenance;
|
|
302
|
-
if (actual === undefined || expected === undefined) {
|
|
303
|
-
return actual === expected;
|
|
304
|
-
}
|
|
305
|
-
return (
|
|
306
|
-
actual.disposition === expected.disposition &&
|
|
307
|
-
actual.kind === expected.kind &&
|
|
308
|
-
actual.planContentHash === expected.planContentHash &&
|
|
309
|
-
actual.reviewPending === expected.reviewPending &&
|
|
310
|
-
actual.safeApplied === expected.safeApplied &&
|
|
311
|
-
(regradeSourceHashMatches(actual.sourceHashBefore, run.report) ||
|
|
312
|
-
run[rawReportHash] === actual.sourceHashBefore) &&
|
|
313
|
-
(regradeSourceHashMatches(actual.sourceHashAfter, run.completionReport) ||
|
|
314
|
-
run[rawCompletionReportHash] === actual.sourceHashAfter) &&
|
|
315
|
-
actual.transitionId === expected.transitionId
|
|
316
|
-
);
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Verify every recorded run at its own stamped lock: recompute the plan
|
|
321
|
-
* content hash and lock hash from the recorded plan and report, then compare
|
|
322
|
-
* with the stamped values.
|
|
323
|
-
*/
|
|
324
|
-
export const verifyRegradeHistoryRuns = (
|
|
325
|
-
artifact: RegradeHistoryArtifact
|
|
326
|
-
): TrailsResult<{ readonly runs: number }, ValidationError> => {
|
|
327
|
-
for (const [index, run] of artifact.runs.entries()) {
|
|
328
|
-
if (regradePlanContentHash(run.plan.plan) !== run.planContentHash) {
|
|
329
|
-
return Result.err(
|
|
330
|
-
new ValidationError('Regrade history run stamp mismatch.', {
|
|
446
|
+
readonly rootDir: string;
|
|
447
|
+
readonly sourceRevision: string;
|
|
448
|
+
}): TrailsResult<RegradeHistorySummary, Error> => {
|
|
449
|
+
if (
|
|
450
|
+
params.current !== undefined &&
|
|
451
|
+
params.artifact.transitionId !== undefined &&
|
|
452
|
+
params.artifact.transitionId !== params.current.id
|
|
453
|
+
) {
|
|
454
|
+
return Result.err(
|
|
455
|
+
new ValidationError(
|
|
456
|
+
'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
|
|
457
|
+
{
|
|
331
458
|
context: {
|
|
332
|
-
|
|
333
|
-
path:
|
|
334
|
-
|
|
459
|
+
history: params.current.id,
|
|
460
|
+
path: params.relativePath,
|
|
461
|
+
plan: params.artifact.transitionId,
|
|
335
462
|
},
|
|
336
|
-
}
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
run.completionReportHash,
|
|
352
|
-
run.completionReport
|
|
353
|
-
) &&
|
|
354
|
-
run[rawCompletionReportHash] !== run.completionReportHash
|
|
355
|
-
) {
|
|
356
|
-
return Result.err(
|
|
357
|
-
new ValidationError('Regrade history run stamp mismatch.', {
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const currentLastRun = params.current?.runs.at(-1);
|
|
468
|
+
if (
|
|
469
|
+
params.current !== undefined &&
|
|
470
|
+
params.artifact.transitionId === undefined &&
|
|
471
|
+
currentLastRun !== undefined &&
|
|
472
|
+
currentLastRun.plan.plan.id !== params.artifact.plan.id
|
|
473
|
+
) {
|
|
474
|
+
return Result.err(
|
|
475
|
+
new ValidationError(
|
|
476
|
+
'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
|
|
477
|
+
{
|
|
358
478
|
context: {
|
|
359
|
-
|
|
360
|
-
path:
|
|
361
|
-
|
|
479
|
+
history: currentLastRun.plan.plan.id,
|
|
480
|
+
path: params.relativePath,
|
|
481
|
+
plan: params.artifact.plan.id,
|
|
362
482
|
},
|
|
363
|
-
}
|
|
364
|
-
)
|
|
365
|
-
|
|
366
|
-
const expectedProvenance = governedProvenanceForRun({
|
|
367
|
-
artifact: run.plan,
|
|
368
|
-
completionReport: run.completionReport,
|
|
369
|
-
planContentHash: run.planContentHash,
|
|
370
|
-
report: run.report,
|
|
371
|
-
});
|
|
372
|
-
if (expectedProvenance.isErr()) {
|
|
373
|
-
return expectedProvenance;
|
|
374
|
-
}
|
|
375
|
-
const transition =
|
|
376
|
-
run.plan.plan.kind === 'vocabulary'
|
|
377
|
-
? governedTransitionForPlan(run.plan.plan)
|
|
378
|
-
: undefined;
|
|
379
|
-
if (
|
|
380
|
-
transition?.provenance.mode === 'regrade-history' &&
|
|
381
|
-
run.provenance === undefined
|
|
382
|
-
) {
|
|
383
|
-
return Result.err(
|
|
384
|
-
new ValidationError('Regrade history run lacks governed provenance.', {
|
|
385
|
-
context: { path: artifact.path, run: index },
|
|
386
|
-
})
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
if (
|
|
390
|
-
run.provenance !== undefined &&
|
|
391
|
-
!governedProvenanceMatchesRun({
|
|
392
|
-
expected: expectedProvenance.value,
|
|
393
|
-
run,
|
|
394
|
-
})
|
|
395
|
-
) {
|
|
396
|
-
return Result.err(
|
|
397
|
-
new ValidationError('Regrade history run provenance mismatch.', {
|
|
398
|
-
context: { path: artifact.path, run: index },
|
|
399
|
-
})
|
|
400
|
-
);
|
|
401
|
-
}
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
);
|
|
402
486
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
487
|
+
const transitionId =
|
|
488
|
+
params.current?.id ??
|
|
489
|
+
params.artifact.transitionId ??
|
|
490
|
+
mintTransitionId(
|
|
491
|
+
regradePlanSlugForBody(params.artifact.plan),
|
|
492
|
+
params.planContentHash,
|
|
493
|
+
params.lockHashAtRun
|
|
494
|
+
);
|
|
495
|
+
const receipt = buildRegradeHistoryReceipt({
|
|
496
|
+
artifact: params.artifact,
|
|
497
|
+
changedFiles: params.changedFiles,
|
|
498
|
+
completedReport: params.completedReport,
|
|
499
|
+
historyPath: params.relativePath,
|
|
500
|
+
...(params.current?.[resolvedReceipt] === undefined
|
|
501
|
+
? {}
|
|
502
|
+
: { prior: params.current[resolvedReceipt] }),
|
|
503
|
+
report: params.report,
|
|
504
|
+
rootDir: params.rootDir,
|
|
505
|
+
sourceRevision: params.sourceRevision,
|
|
506
|
+
transitionId,
|
|
507
|
+
});
|
|
508
|
+
if (receipt.isErr()) {
|
|
509
|
+
return receipt;
|
|
510
|
+
}
|
|
511
|
+
const serialized = serializeRegradeHistoryReceipt(receipt.value);
|
|
512
|
+
if (serialized.isErr()) {
|
|
513
|
+
return serialized;
|
|
416
514
|
}
|
|
515
|
+
const written = writeRegradeHistoryFileAtomically({
|
|
516
|
+
absolutePath: params.absolutePath,
|
|
517
|
+
content: serialized.value,
|
|
518
|
+
diagnosticPath: params.relativePath,
|
|
519
|
+
});
|
|
520
|
+
if (written.isErr()) {
|
|
521
|
+
return written;
|
|
522
|
+
}
|
|
523
|
+
const lastRun = receipt.value.runs.at(-1);
|
|
417
524
|
return Result.ok({
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
planContentHash: params.planContentHash,
|
|
423
|
-
...(provenance.value === undefined ? {} : { provenance: provenance.value }),
|
|
424
|
-
report: params.report,
|
|
525
|
+
id: receipt.value.id,
|
|
526
|
+
path: receipt.value.path,
|
|
527
|
+
schemaVersion: receipt.value.schemaVersion,
|
|
528
|
+
status: lastRun?.runKind === 'proof' ? 'replay' : 'applied',
|
|
425
529
|
});
|
|
426
530
|
};
|
|
427
531
|
|
|
428
|
-
const historySummaryFor = (
|
|
429
|
-
artifact: Pick<RegradeHistoryArtifact, 'id' | 'path' | 'schemaVersion'>,
|
|
430
|
-
status: RegradeHistorySummary['status'],
|
|
431
|
-
provenance?: GovernedVocabularyHistoryProvenance
|
|
432
|
-
): RegradeHistorySummary => ({
|
|
433
|
-
id: artifact.id,
|
|
434
|
-
path: artifact.path,
|
|
435
|
-
...(provenance === undefined ? {} : { provenance }),
|
|
436
|
-
schemaVersion: artifact.schemaVersion,
|
|
437
|
-
status,
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
const writableHistoryArtifact = (artifact: RegradeHistoryArtifact) => ({
|
|
441
|
-
id: artifact.id,
|
|
442
|
-
kind: artifact.kind,
|
|
443
|
-
path: artifact.path,
|
|
444
|
-
runs: artifact.runs.map((run) => ({
|
|
445
|
-
completionReport: run[rawCompletionReport] ?? run.completionReport,
|
|
446
|
-
completionReportHash: run.completionReportHash,
|
|
447
|
-
lockHashAtRun: run.lockHashAtRun,
|
|
448
|
-
plan: run.plan,
|
|
449
|
-
planContentHash: run.planContentHash,
|
|
450
|
-
...(run.provenance === undefined ? {} : { provenance: run.provenance }),
|
|
451
|
-
report: run[rawReport] ?? run.report,
|
|
452
|
-
})),
|
|
453
|
-
schemaVersion: artifact.schemaVersion,
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
const nextHistoryArtifact = (params: {
|
|
457
|
-
readonly entry: RegradeHistoryRun;
|
|
458
|
-
readonly lockHashAtRun: string;
|
|
459
|
-
readonly path: string;
|
|
460
|
-
readonly plan: RegradePlanArtifact;
|
|
461
|
-
readonly planContentHash: string;
|
|
462
|
-
readonly prior: RegradeHistoryArtifact | undefined;
|
|
463
|
-
}): RegradeHistoryArtifact => ({
|
|
464
|
-
id:
|
|
465
|
-
params.prior?.id ??
|
|
466
|
-
params.plan.transitionId ??
|
|
467
|
-
mintTransitionId(
|
|
468
|
-
regradePlanSlugForBody(params.plan.plan),
|
|
469
|
-
params.planContentHash,
|
|
470
|
-
params.lockHashAtRun
|
|
471
|
-
),
|
|
472
|
-
kind: 'regrade-history',
|
|
473
|
-
path: params.path,
|
|
474
|
-
runs:
|
|
475
|
-
params.prior === undefined
|
|
476
|
-
? [params.entry]
|
|
477
|
-
: [...params.prior.runs, params.entry],
|
|
478
|
-
schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
|
|
479
|
-
});
|
|
480
|
-
|
|
481
532
|
/**
|
|
482
533
|
* Append one applied run to the transition's consolidated history file. A
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
* `status: 'replay'` is surfaced instead of a duplicate record.
|
|
534
|
+
* Unchanged intent and classified state append a compact reference-only proof
|
|
535
|
+
* and surface `status: 'replay'`.
|
|
486
536
|
*/
|
|
487
537
|
export const appendRegradeHistoryRun = (params: {
|
|
488
538
|
readonly artifact: RegradePlanArtifact;
|
|
539
|
+
readonly changedFiles?: readonly RegradeChangedFileEvidence[];
|
|
489
540
|
readonly completedReport?: RegradeReport;
|
|
490
541
|
readonly report: RegradeReport;
|
|
491
542
|
readonly rootDir: string;
|
|
543
|
+
readonly sourceRevision?: string;
|
|
492
544
|
}): TrailsResult<RegradeHistorySummary, Error> => {
|
|
493
545
|
const absolutePath = regradeHistoryPathForPlan(
|
|
494
546
|
params.rootDir,
|
|
@@ -497,142 +549,88 @@ export const appendRegradeHistoryRun = (params: {
|
|
|
497
549
|
const relativePath = rootRelativePath(params.rootDir, absolutePath);
|
|
498
550
|
const planContentHash = regradePlanContentHash(params.artifact.plan);
|
|
499
551
|
const lockHashAtRun = regradeSourceHash(params.report);
|
|
500
|
-
const currentSourceHashes = regradeSourceHashes(params.report);
|
|
501
552
|
const completionReport = params.completedReport ?? params.report;
|
|
502
|
-
const entry = historyEntryFor({
|
|
503
|
-
artifact: params.artifact,
|
|
504
|
-
completionReport,
|
|
505
|
-
lockHashAtRun,
|
|
506
|
-
planContentHash,
|
|
507
|
-
report: params.report,
|
|
508
|
-
});
|
|
509
|
-
if (entry.isErr()) {
|
|
510
|
-
return entry;
|
|
511
|
-
}
|
|
512
553
|
|
|
513
|
-
let
|
|
554
|
+
let current: RegradeHistoryArtifact | undefined;
|
|
514
555
|
if (existsSync(absolutePath)) {
|
|
515
556
|
const existing = readRegradeHistoryArtifact(absolutePath);
|
|
516
557
|
if (existing.isErr()) {
|
|
517
558
|
return existing;
|
|
518
559
|
}
|
|
519
|
-
|
|
520
|
-
const verified = verifyRegradeHistoryRuns(prior);
|
|
521
|
-
if (verified.isErr()) {
|
|
522
|
-
return verified;
|
|
523
|
-
}
|
|
524
|
-
if (
|
|
525
|
-
params.artifact.transitionId !== undefined &&
|
|
526
|
-
params.artifact.transitionId !== prior.id
|
|
527
|
-
) {
|
|
528
|
-
return Result.err(
|
|
529
|
-
new ValidationError(
|
|
530
|
-
'Regrade plan transition id mismatch — refusing to fork the consolidated history.',
|
|
531
|
-
{
|
|
532
|
-
context: {
|
|
533
|
-
history: prior.id,
|
|
534
|
-
path: relativePath,
|
|
535
|
-
plan: params.artifact.transitionId,
|
|
536
|
-
},
|
|
537
|
-
}
|
|
538
|
-
)
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
const lastRun = prior.runs.at(-1);
|
|
542
|
-
// A plan that carries the transition id (adjust round-trips, plan
|
|
543
|
-
// re-derivation) may evolve the plan identity on the same spine. A plan
|
|
544
|
-
// WITHOUT the id that disagrees with the recorded plan identity is a
|
|
545
|
-
// name collision, not a continuation — refuse instead of mixing runs
|
|
546
|
-
// from unrelated transitions into one history.
|
|
547
|
-
if (
|
|
548
|
-
params.artifact.transitionId === undefined &&
|
|
549
|
-
lastRun !== undefined &&
|
|
550
|
-
lastRun.plan.plan.id !== params.artifact.plan.id
|
|
551
|
-
) {
|
|
552
|
-
return Result.err(
|
|
553
|
-
new ValidationError(
|
|
554
|
-
'Regrade history already records a different plan identity under this transition name. Use `regrade adjust <transition>` to continue it, or pick a different plan name.',
|
|
555
|
-
{
|
|
556
|
-
context: {
|
|
557
|
-
history: lastRun.plan.plan.id,
|
|
558
|
-
path: relativePath,
|
|
559
|
-
plan: params.artifact.plan.id,
|
|
560
|
-
},
|
|
561
|
-
}
|
|
562
|
-
)
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
if (
|
|
566
|
-
lastRun !== undefined &&
|
|
567
|
-
lastRun.planContentHash === planContentHash &&
|
|
568
|
-
(currentSourceHashes.includes(lastRun.lockHashAtRun) ||
|
|
569
|
-
currentSourceHashes.includes(lastRun.completionReportHash))
|
|
570
|
-
) {
|
|
571
|
-
return Result.ok(historySummaryFor(prior, 'replay', lastRun.provenance));
|
|
572
|
-
}
|
|
560
|
+
current = existing.value;
|
|
573
561
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
562
|
+
const sourceRevision =
|
|
563
|
+
params.sourceRevision === undefined
|
|
564
|
+
? resolveRegradeSourceRevision(params.rootDir)
|
|
565
|
+
: Result.ok(params.sourceRevision);
|
|
566
|
+
if (sourceRevision.isErr()) {
|
|
567
|
+
return sourceRevision;
|
|
568
|
+
}
|
|
569
|
+
return appendReceiptHistoryRun({
|
|
570
|
+
absolutePath,
|
|
571
|
+
artifact: params.artifact,
|
|
572
|
+
changedFiles: params.changedFiles ?? [],
|
|
573
|
+
completedReport: completionReport,
|
|
574
|
+
current,
|
|
577
575
|
lockHashAtRun,
|
|
578
|
-
path: relativePath,
|
|
579
|
-
plan: params.artifact,
|
|
580
576
|
planContentHash,
|
|
581
|
-
|
|
577
|
+
relativePath,
|
|
578
|
+
report: params.report,
|
|
579
|
+
rootDir: params.rootDir,
|
|
580
|
+
sourceRevision: sourceRevision.value,
|
|
582
581
|
});
|
|
583
|
-
const writableArtifact = writableHistoryArtifact(artifact);
|
|
584
|
-
const parsed = regradeHistoryArtifactSchema.safeParse(writableArtifact);
|
|
585
|
-
if (!parsed.success) {
|
|
586
|
-
return Result.err(
|
|
587
|
-
new ValidationError('Invalid Regrade history artifact.', {
|
|
588
|
-
context: { issues: parsed.error.issues, path: relativePath },
|
|
589
|
-
})
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
try {
|
|
593
|
-
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
594
|
-
writeFileSync(
|
|
595
|
-
absolutePath,
|
|
596
|
-
`${JSON.stringify(writableArtifact, null, 2)}\n`
|
|
597
|
-
);
|
|
598
|
-
} catch (error) {
|
|
599
|
-
return Result.err(
|
|
600
|
-
new InternalError('Failed to write Regrade history entry.', {
|
|
601
|
-
...(error instanceof Error ? { cause: error } : {}),
|
|
602
|
-
context: { path: relativePath },
|
|
603
|
-
})
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
return Result.ok(
|
|
607
|
-
historySummaryFor(artifact, 'applied', entry.value.provenance)
|
|
608
|
-
);
|
|
609
582
|
};
|
|
610
583
|
|
|
611
584
|
const hasPathSeparator = (value: string): boolean =>
|
|
612
585
|
value.includes('/') || value.includes('\\');
|
|
613
586
|
|
|
614
587
|
/**
|
|
615
|
-
* Resolve
|
|
616
|
-
*
|
|
617
|
-
*
|
|
588
|
+
* Resolve an opaque transition id to its validated consolidated history file.
|
|
589
|
+
* Path and filename references are rejected; the operator never selects the
|
|
590
|
+
* generator-owned receipt filename.
|
|
618
591
|
*/
|
|
619
592
|
export const resolveRegradeHistoryPath = (
|
|
620
593
|
rootDir: string,
|
|
621
594
|
ref: string
|
|
622
|
-
): TrailsResult<string,
|
|
595
|
+
): TrailsResult<string, Error> => {
|
|
623
596
|
if (hasPathSeparator(ref) || isAbsolute(ref)) {
|
|
624
597
|
return Result.err(
|
|
625
598
|
new ValidationError(
|
|
626
|
-
`Regrade history reference "${ref}" must be
|
|
599
|
+
`Regrade history reference "${ref}" must be an opaque transition id.`
|
|
627
600
|
)
|
|
628
601
|
);
|
|
629
602
|
}
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
if (!existsSync(path)) {
|
|
603
|
+
const historyDir = join(regradePlanDirectory(rootDir), 'history');
|
|
604
|
+
if (!existsSync(historyDir)) {
|
|
633
605
|
return Result.err(
|
|
634
606
|
new ValidationError(`No Regrade history for transition "${ref}" found.`)
|
|
635
607
|
);
|
|
636
608
|
}
|
|
637
|
-
|
|
609
|
+
const matches: string[] = [];
|
|
610
|
+
for (const name of readdirSync(historyDir).toSorted()) {
|
|
611
|
+
if (!name.endsWith('.json')) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const path = join(historyDir, name);
|
|
615
|
+
const history = readRegradeHistoryArtifact(path);
|
|
616
|
+
if (history.isErr()) {
|
|
617
|
+
return history;
|
|
618
|
+
}
|
|
619
|
+
if (history.value.id === ref) {
|
|
620
|
+
matches.push(path);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (matches.length === 0) {
|
|
624
|
+
return Result.err(
|
|
625
|
+
new ValidationError(`No Regrade history for transition "${ref}" found.`)
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
if (matches.length > 1) {
|
|
629
|
+
return Result.err(
|
|
630
|
+
new ValidationError(
|
|
631
|
+
`Multiple Regrade histories claim transition "${ref}".`
|
|
632
|
+
)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
return Result.ok(matches[0] as string);
|
|
638
636
|
};
|