@vitronai/themis 0.1.0-beta.4 → 0.1.1
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 +11 -1
- package/README.md +42 -3
- package/docs/api.md +21 -2
- package/docs/migration.md +167 -0
- package/docs/publish.md +2 -0
- package/docs/release-checklist.md +28 -0
- package/docs/release-policy.md +11 -8
- package/docs/roadmap.md +18 -0
- package/docs/schemas/agent-result.v1.json +8 -2
- package/docs/schemas/contract-diff.v1.json +120 -0
- package/docs/showcases.md +117 -0
- package/docs/vscode-extension.md +13 -0
- package/docs/why-themis.md +34 -3
- package/globals.d.ts +1 -0
- package/index.d.ts +57 -0
- package/package.json +1 -1
- package/src/artifacts.js +63 -2
- package/src/assets/themisVerdictEngine.png +0 -0
- package/src/cli.js +16 -3
- package/src/contracts.js +330 -0
- package/src/migrate.js +104 -4
- package/src/reporter.js +79 -3
- package/src/runner.js +2 -0
- package/src/runtime.js +32 -3
- package/src/test-utils.js +13 -0
- package/src/worker.js +1 -0
package/src/contracts.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const util = require('util');
|
|
4
|
+
|
|
5
|
+
const CONTRACT_ARTIFACT_DIR = path.join('.themis', 'contracts');
|
|
6
|
+
|
|
7
|
+
function createContractHarness(options = {}) {
|
|
8
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
9
|
+
const getCurrentTest = typeof options.getCurrentTest === 'function'
|
|
10
|
+
? options.getCurrentTest
|
|
11
|
+
: () => null;
|
|
12
|
+
const updateContracts = Boolean(options.updateContracts);
|
|
13
|
+
const events = [];
|
|
14
|
+
|
|
15
|
+
function captureContract(name, value, contractOptions = {}) {
|
|
16
|
+
const currentTest = getCurrentTest();
|
|
17
|
+
if (!currentTest || !currentTest.fullName) {
|
|
18
|
+
throw new Error('captureContract(...) is only available while a Themis test is running');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const contractName = String(name || '').trim();
|
|
22
|
+
if (!contractName) {
|
|
23
|
+
throw new Error('captureContract(...) requires a non-empty contract name');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const normalizedValue = applyContractOptions(value, contractOptions);
|
|
27
|
+
const contractKey = buildContractKey(currentTest, contractName);
|
|
28
|
+
const relativeFile = contractOptions.file
|
|
29
|
+
? normalizeRelativeContractPath(contractOptions.file)
|
|
30
|
+
: path.join(CONTRACT_ARTIFACT_DIR, `${contractKey}.json`).split(path.sep).join('/');
|
|
31
|
+
const absoluteFile = path.join(cwd, relativeFile);
|
|
32
|
+
const previous = readExistingContract(absoluteFile);
|
|
33
|
+
const diff = previous.exists
|
|
34
|
+
? createContractDiff(previous.value, normalizedValue)
|
|
35
|
+
: createCreatedContractDiff(normalizedValue);
|
|
36
|
+
|
|
37
|
+
let status = 'unchanged';
|
|
38
|
+
if (!previous.exists) {
|
|
39
|
+
status = 'created';
|
|
40
|
+
ensureContractDirectory(absoluteFile);
|
|
41
|
+
fs.writeFileSync(absoluteFile, `${JSON.stringify(normalizedValue, null, 2)}\n`, 'utf8');
|
|
42
|
+
} else if (!diff.equal && updateContracts) {
|
|
43
|
+
status = 'updated';
|
|
44
|
+
ensureContractDirectory(absoluteFile);
|
|
45
|
+
fs.writeFileSync(absoluteFile, `${JSON.stringify(normalizedValue, null, 2)}\n`, 'utf8');
|
|
46
|
+
} else if (!diff.equal) {
|
|
47
|
+
status = 'drifted';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const event = {
|
|
51
|
+
key: contractKey,
|
|
52
|
+
name: contractName,
|
|
53
|
+
status,
|
|
54
|
+
contractFile: relativeFile,
|
|
55
|
+
file: currentTest.file,
|
|
56
|
+
testName: currentTest.name,
|
|
57
|
+
fullName: currentTest.fullName,
|
|
58
|
+
updateCommand: `npx themis test --update-contracts --match ${JSON.stringify(currentTest.fullName)}`,
|
|
59
|
+
diff
|
|
60
|
+
};
|
|
61
|
+
events.push(event);
|
|
62
|
+
|
|
63
|
+
if (status === 'drifted') {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Contract "${contractName}" drifted for ${currentTest.fullName}. ` +
|
|
66
|
+
`Review ${relativeFile} and rerun with --update-contracts to accept the new contract.\n` +
|
|
67
|
+
formatContractDiff(diff)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return normalizedValue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
captureContract,
|
|
76
|
+
getEvents() {
|
|
77
|
+
return events.map((event) => JSON.parse(JSON.stringify(event)));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeContractValue(value) {
|
|
83
|
+
if (value === null || typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
if (value === undefined) {
|
|
87
|
+
return '[undefined]';
|
|
88
|
+
}
|
|
89
|
+
if (typeof value === 'bigint') {
|
|
90
|
+
return `${value}n`;
|
|
91
|
+
}
|
|
92
|
+
if (typeof value === 'function') {
|
|
93
|
+
return `[Function ${value.name || 'anonymous'}]`;
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === 'symbol') {
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
if (value instanceof Date) {
|
|
99
|
+
return value.toISOString();
|
|
100
|
+
}
|
|
101
|
+
if (Buffer.isBuffer(value)) {
|
|
102
|
+
return {
|
|
103
|
+
type: 'Buffer',
|
|
104
|
+
data: value.toString('base64')
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
return value.map((entry) => normalizeContractValue(entry));
|
|
109
|
+
}
|
|
110
|
+
if (value instanceof Map) {
|
|
111
|
+
return [...value.entries()]
|
|
112
|
+
.map(([key, entryValue]) => [normalizeContractValue(key), normalizeContractValue(entryValue)])
|
|
113
|
+
.sort(compareContractEntries);
|
|
114
|
+
}
|
|
115
|
+
if (value instanceof Set) {
|
|
116
|
+
return [...value.values()]
|
|
117
|
+
.map((entry) => normalizeContractValue(entry))
|
|
118
|
+
.sort(compareNormalizedValues);
|
|
119
|
+
}
|
|
120
|
+
if (value && typeof value === 'object') {
|
|
121
|
+
const normalized = {};
|
|
122
|
+
for (const key of Object.keys(value).sort()) {
|
|
123
|
+
normalized[key] = normalizeContractValue(value[key]);
|
|
124
|
+
}
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
return String(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyContractOptions(value, contractOptions = {}) {
|
|
131
|
+
let nextValue = typeof contractOptions.normalize === 'function'
|
|
132
|
+
? contractOptions.normalize(value)
|
|
133
|
+
: value;
|
|
134
|
+
|
|
135
|
+
nextValue = normalizeContractValue(nextValue);
|
|
136
|
+
|
|
137
|
+
if (Array.isArray(contractOptions.maskPaths) && contractOptions.maskPaths.length > 0) {
|
|
138
|
+
nextValue = maskContractPaths(nextValue, contractOptions.maskPaths);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (contractOptions.sortArrays) {
|
|
142
|
+
nextValue = sortNormalizedArrays(nextValue);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return nextValue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compareContractEntries(left, right) {
|
|
149
|
+
return JSON.stringify(left).localeCompare(JSON.stringify(right));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compareNormalizedValues(left, right) {
|
|
153
|
+
return JSON.stringify(left).localeCompare(JSON.stringify(right));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeRelativeContractPath(filePath) {
|
|
157
|
+
const normalized = String(filePath || '').split(path.sep).join('/');
|
|
158
|
+
if (!normalized.startsWith('.themis/')) {
|
|
159
|
+
return path.join(CONTRACT_ARTIFACT_DIR, normalized).split(path.sep).join('/');
|
|
160
|
+
}
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildContractKey(test, name) {
|
|
165
|
+
const parts = [test.file, test.fullName, name]
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map((part) => String(part).toLowerCase())
|
|
168
|
+
.join(' ');
|
|
169
|
+
return slugify(parts);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function slugify(value) {
|
|
173
|
+
const slug = String(value || '')
|
|
174
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
175
|
+
.replace(/^-+|-+$/g, '')
|
|
176
|
+
.replace(/-{2,}/g, '-')
|
|
177
|
+
.toLowerCase();
|
|
178
|
+
return slug || 'contract';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ensureContractDirectory(filePath) {
|
|
182
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readExistingContract(filePath) {
|
|
186
|
+
if (!fs.existsSync(filePath)) {
|
|
187
|
+
return {
|
|
188
|
+
exists: false,
|
|
189
|
+
value: null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
194
|
+
try {
|
|
195
|
+
return {
|
|
196
|
+
exists: true,
|
|
197
|
+
value: JSON.parse(raw)
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new Error(`Failed to parse existing contract at ${filePath}: ${String(error.message || error)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createCreatedContractDiff(nextValue) {
|
|
205
|
+
const flat = flattenContractValue(nextValue);
|
|
206
|
+
return {
|
|
207
|
+
equal: false,
|
|
208
|
+
added: flat.slice(0, 12).map((entry) => ({ path: entry.path, after: entry.value })),
|
|
209
|
+
removed: [],
|
|
210
|
+
changed: [],
|
|
211
|
+
unchangedCount: 0
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function createContractDiff(previousValue, nextValue) {
|
|
216
|
+
const previousFlat = flattenContractValue(previousValue);
|
|
217
|
+
const nextFlat = flattenContractValue(nextValue);
|
|
218
|
+
const previousMap = new Map(previousFlat.map((entry) => [entry.path, entry.value]));
|
|
219
|
+
const nextMap = new Map(nextFlat.map((entry) => [entry.path, entry.value]));
|
|
220
|
+
const allPaths = [...new Set([...previousMap.keys(), ...nextMap.keys()])].sort();
|
|
221
|
+
const added = [];
|
|
222
|
+
const removed = [];
|
|
223
|
+
const changed = [];
|
|
224
|
+
let unchangedCount = 0;
|
|
225
|
+
|
|
226
|
+
for (const entryPath of allPaths) {
|
|
227
|
+
const hasPrevious = previousMap.has(entryPath);
|
|
228
|
+
const hasNext = nextMap.has(entryPath);
|
|
229
|
+
if (!hasPrevious && hasNext) {
|
|
230
|
+
added.push({ path: entryPath, after: nextMap.get(entryPath) });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (hasPrevious && !hasNext) {
|
|
234
|
+
removed.push({ path: entryPath, before: previousMap.get(entryPath) });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const before = previousMap.get(entryPath);
|
|
238
|
+
const after = nextMap.get(entryPath);
|
|
239
|
+
if (util.isDeepStrictEqual(before, after)) {
|
|
240
|
+
unchangedCount += 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
changed.push({ path: entryPath, before, after });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
equal: added.length === 0 && removed.length === 0 && changed.length === 0,
|
|
248
|
+
added: added.slice(0, 12),
|
|
249
|
+
removed: removed.slice(0, 12),
|
|
250
|
+
changed: changed.slice(0, 12),
|
|
251
|
+
unchangedCount
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function flattenContractValue(value, currentPath = '$') {
|
|
256
|
+
if (Array.isArray(value)) {
|
|
257
|
+
if (value.length === 0) {
|
|
258
|
+
return [{ path: currentPath, value: [] }];
|
|
259
|
+
}
|
|
260
|
+
return value.flatMap((entry, index) => flattenContractValue(entry, `${currentPath}[${index}]`));
|
|
261
|
+
}
|
|
262
|
+
if (value && typeof value === 'object') {
|
|
263
|
+
const keys = Object.keys(value);
|
|
264
|
+
if (keys.length === 0) {
|
|
265
|
+
return [{ path: currentPath, value: {} }];
|
|
266
|
+
}
|
|
267
|
+
return keys.flatMap((key) => flattenContractValue(value[key], `${currentPath}.${key}`));
|
|
268
|
+
}
|
|
269
|
+
return [{ path: currentPath, value }];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function formatContractDiff(diff) {
|
|
273
|
+
const lines = [];
|
|
274
|
+
for (const entry of diff.changed.slice(0, 4)) {
|
|
275
|
+
lines.push(`changed ${entry.path}: ${formatValue(entry.before)} -> ${formatValue(entry.after)}`);
|
|
276
|
+
}
|
|
277
|
+
for (const entry of diff.added.slice(0, 4)) {
|
|
278
|
+
lines.push(`added ${entry.path}: ${formatValue(entry.after)}`);
|
|
279
|
+
}
|
|
280
|
+
for (const entry of diff.removed.slice(0, 4)) {
|
|
281
|
+
lines.push(`removed ${entry.path}: ${formatValue(entry.before)}`);
|
|
282
|
+
}
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function maskContractPaths(value, maskPaths) {
|
|
287
|
+
const targets = new Set(maskPaths.map((entry) => String(entry)));
|
|
288
|
+
return visitContractValue(value, '$', (currentValue, currentPath) => {
|
|
289
|
+
if (targets.has(currentPath)) {
|
|
290
|
+
return '[masked]';
|
|
291
|
+
}
|
|
292
|
+
return currentValue;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function sortNormalizedArrays(value) {
|
|
297
|
+
return visitContractValue(value, '$', (currentValue) => {
|
|
298
|
+
if (!Array.isArray(currentValue)) {
|
|
299
|
+
return currentValue;
|
|
300
|
+
}
|
|
301
|
+
return [...currentValue].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function visitContractValue(value, currentPath, visitor) {
|
|
306
|
+
if (Array.isArray(value)) {
|
|
307
|
+
const mapped = value.map((entry, index) => visitContractValue(entry, `${currentPath}[${index}]`, visitor));
|
|
308
|
+
return visitor(mapped, currentPath);
|
|
309
|
+
}
|
|
310
|
+
if (value && typeof value === 'object') {
|
|
311
|
+
const mapped = {};
|
|
312
|
+
for (const key of Object.keys(value)) {
|
|
313
|
+
mapped[key] = visitContractValue(value[key], `${currentPath}.${key}`, visitor);
|
|
314
|
+
}
|
|
315
|
+
return visitor(mapped, currentPath);
|
|
316
|
+
}
|
|
317
|
+
return visitor(value, currentPath);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function formatValue(value) {
|
|
321
|
+
return util.inspect(value, { depth: 4, colors: false, maxArrayLength: 10 });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
CONTRACT_ARTIFACT_DIR,
|
|
326
|
+
createContractHarness,
|
|
327
|
+
createContractDiff,
|
|
328
|
+
formatContractDiff,
|
|
329
|
+
normalizeContractValue
|
|
330
|
+
};
|
package/src/migrate.js
CHANGED
|
@@ -60,7 +60,10 @@ function runMigrate(cwd, framework, options = {}) {
|
|
|
60
60
|
const rewriteSummary = options.rewriteImports
|
|
61
61
|
? rewriteMigrationImports(projectRoot, scan.matches, compatPath)
|
|
62
62
|
: { rewrittenFiles: [], rewrittenImports: 0 };
|
|
63
|
-
const
|
|
63
|
+
const conversionSummary = options.convert
|
|
64
|
+
? convertMigrationFiles(projectRoot, scan.matches)
|
|
65
|
+
: { convertedFiles: [], convertedAssertions: 0, removedImports: 0 };
|
|
66
|
+
const report = buildMigrationReport(projectRoot, source, scan.matches, rewriteSummary, conversionSummary);
|
|
64
67
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
65
68
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
66
69
|
|
|
@@ -74,7 +77,9 @@ function runMigrate(cwd, framework, options = {}) {
|
|
|
74
77
|
reportPath,
|
|
75
78
|
report,
|
|
76
79
|
rewriteImports: Boolean(options.rewriteImports),
|
|
77
|
-
rewrittenFiles: rewriteSummary.rewrittenFiles
|
|
80
|
+
rewrittenFiles: rewriteSummary.rewrittenFiles,
|
|
81
|
+
convert: Boolean(options.convert),
|
|
82
|
+
convertedFiles: conversionSummary.convertedFiles
|
|
78
83
|
};
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -155,7 +160,13 @@ function scanMigrationFiles(projectRoot) {
|
|
|
155
160
|
};
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
function buildMigrationReport(
|
|
163
|
+
function buildMigrationReport(
|
|
164
|
+
projectRoot,
|
|
165
|
+
source,
|
|
166
|
+
matches,
|
|
167
|
+
rewriteSummary = { rewrittenFiles: [], rewrittenImports: 0 },
|
|
168
|
+
conversionSummary = { convertedFiles: [], convertedAssertions: 0, removedImports: 0 }
|
|
169
|
+
) {
|
|
159
170
|
return {
|
|
160
171
|
schema: 'themis.migration.report.v1',
|
|
161
172
|
source,
|
|
@@ -166,7 +177,10 @@ function buildMigrationReport(projectRoot, source, matches, rewriteSummary = { r
|
|
|
166
177
|
vitest: matches.filter((entry) => entry.imports.includes('vitest')).length,
|
|
167
178
|
testingLibraryReact: matches.filter((entry) => entry.imports.includes('@testing-library/react')).length,
|
|
168
179
|
rewrittenFiles: Array.isArray(rewriteSummary.rewrittenFiles) ? rewriteSummary.rewrittenFiles.length : 0,
|
|
169
|
-
rewrittenImports: Number(rewriteSummary.rewrittenImports || 0)
|
|
180
|
+
rewrittenImports: Number(rewriteSummary.rewrittenImports || 0),
|
|
181
|
+
convertedFiles: Array.isArray(conversionSummary.convertedFiles) ? conversionSummary.convertedFiles.length : 0,
|
|
182
|
+
convertedAssertions: Number(conversionSummary.convertedAssertions || 0),
|
|
183
|
+
removedImports: Number(conversionSummary.removedImports || 0)
|
|
170
184
|
},
|
|
171
185
|
files: matches,
|
|
172
186
|
nextActions: [
|
|
@@ -176,10 +190,96 @@ function buildMigrationReport(projectRoot, source, matches, rewriteSummary = { r
|
|
|
176
190
|
],
|
|
177
191
|
rewrites: Array.isArray(rewriteSummary.rewrittenFiles)
|
|
178
192
|
? rewriteSummary.rewrittenFiles
|
|
193
|
+
: [],
|
|
194
|
+
conversions: Array.isArray(conversionSummary.convertedFiles)
|
|
195
|
+
? conversionSummary.convertedFiles
|
|
179
196
|
: []
|
|
180
197
|
};
|
|
181
198
|
}
|
|
182
199
|
|
|
200
|
+
function convertMigrationFiles(projectRoot, matches) {
|
|
201
|
+
const convertedFiles = [];
|
|
202
|
+
let convertedAssertions = 0;
|
|
203
|
+
let removedImports = 0;
|
|
204
|
+
|
|
205
|
+
for (const match of matches) {
|
|
206
|
+
const absoluteFile = path.join(projectRoot, match.file);
|
|
207
|
+
const original = fs.readFileSync(absoluteFile, 'utf8');
|
|
208
|
+
const converted = convertMigrationSourceText(original);
|
|
209
|
+
if (converted.source !== original) {
|
|
210
|
+
fs.writeFileSync(absoluteFile, converted.source, 'utf8');
|
|
211
|
+
convertedFiles.push(match.file);
|
|
212
|
+
convertedAssertions += converted.convertedAssertions;
|
|
213
|
+
removedImports += converted.removedImports;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
convertedFiles,
|
|
219
|
+
convertedAssertions,
|
|
220
|
+
removedImports
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function convertMigrationSourceText(sourceText) {
|
|
225
|
+
let source = sourceText;
|
|
226
|
+
let convertedAssertions = 0;
|
|
227
|
+
let removedImports = 0;
|
|
228
|
+
|
|
229
|
+
source = source.replace(
|
|
230
|
+
/^\s*import\s+\{[^}]*\}\s+from\s+['"]@jest\/globals['"];\s*\n?/gm,
|
|
231
|
+
() => {
|
|
232
|
+
removedImports += 1;
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
source = source.replace(
|
|
237
|
+
/^\s*import\s+\{[^}]*\}\s+from\s+['"]vitest['"];\s*\n?/gm,
|
|
238
|
+
() => {
|
|
239
|
+
removedImports += 1;
|
|
240
|
+
return '';
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
source = source.replace(
|
|
244
|
+
/^\s*import\s+\{[^}]*\}\s+from\s+['"]@testing-library\/react['"];\s*\n?/gm,
|
|
245
|
+
() => {
|
|
246
|
+
removedImports += 1;
|
|
247
|
+
return '';
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const replacements = [
|
|
252
|
+
{ pattern: /\bit\s*\(/g, replacement: 'test(' },
|
|
253
|
+
{ pattern: /\btest\.only\s*\(/g, replacement: 'test(' },
|
|
254
|
+
{ pattern: /\bit\.only\s*\(/g, replacement: 'test(' },
|
|
255
|
+
{ pattern: /\btest\.skip\s*\(/g, replacement: 'test.skip(' },
|
|
256
|
+
{ pattern: /\bit\.skip\s*\(/g, replacement: 'test.skip(' },
|
|
257
|
+
{ pattern: /\.toStrictEqual\s*\(/g, replacement: '.toEqual(' },
|
|
258
|
+
{ pattern: /\.toContainEqual\s*\(/g, replacement: '.toContain(' },
|
|
259
|
+
{ pattern: /\.toBeCalledTimes\s*\(/g, replacement: '.toHaveBeenCalledTimes(' },
|
|
260
|
+
{ pattern: /\.toBeCalledWith\s*\(/g, replacement: '.toHaveBeenCalledWith(' },
|
|
261
|
+
{ pattern: /\.toBeCalled\s*\(/g, replacement: '.toHaveBeenCalled(' },
|
|
262
|
+
{ pattern: /\.lastCalledWith\s*\(/g, replacement: '.toHaveBeenCalledWith(' },
|
|
263
|
+
{ pattern: /\.toBeTruthy\s*\(\s*\)/g, replacement: '.toBeTruthy()' },
|
|
264
|
+
{ pattern: /\.toBeFalsy\s*\(\s*\)/g, replacement: '.toBeFalsy()' }
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
for (const entry of replacements) {
|
|
268
|
+
source = source.replace(entry.pattern, () => {
|
|
269
|
+
convertedAssertions += 1;
|
|
270
|
+
return entry.replacement;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
source = source.replace(/\n{3,}/g, '\n\n');
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
source,
|
|
278
|
+
convertedAssertions,
|
|
279
|
+
removedImports
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
183
283
|
function rewriteMigrationImports(projectRoot, matches, compatPath) {
|
|
184
284
|
const rewrittenFiles = [];
|
|
185
285
|
let rewrittenImports = 0;
|
package/src/reporter.js
CHANGED
|
@@ -67,7 +67,8 @@ function printAgent(result) {
|
|
|
67
67
|
failedTests: '.themis/failed-tests.json',
|
|
68
68
|
runDiff: '.themis/run-diff.json',
|
|
69
69
|
runHistory: '.themis/run-history.json',
|
|
70
|
-
fixHandoff: '.themis/fix-handoff.json'
|
|
70
|
+
fixHandoff: '.themis/fix-handoff.json',
|
|
71
|
+
contractDiff: '.themis/contract-diff.json'
|
|
71
72
|
};
|
|
72
73
|
|
|
73
74
|
const payload = {
|
|
@@ -86,7 +87,8 @@ function printAgent(result) {
|
|
|
86
87
|
rerunFailed: 'npx themis test --rerun-failed',
|
|
87
88
|
targetedRerun: 'npx themis test --match "<regex>"',
|
|
88
89
|
diffLastRun: 'cat .themis/run-diff.json',
|
|
89
|
-
repairGenerated: 'cat .themis/fix-handoff.json'
|
|
90
|
+
repairGenerated: 'cat .themis/fix-handoff.json',
|
|
91
|
+
reviewContracts: 'cat .themis/contract-diff.json'
|
|
90
92
|
}
|
|
91
93
|
};
|
|
92
94
|
|
|
@@ -150,6 +152,8 @@ function printNext(result, options = {}) {
|
|
|
150
152
|
.slice(0, 5);
|
|
151
153
|
|
|
152
154
|
const failures = allTests.filter((test) => test.status === 'failed');
|
|
155
|
+
const contractItems = collectContractItems(files);
|
|
156
|
+
const notableContracts = contractItems.filter((item) => item.status !== 'unchanged');
|
|
153
157
|
|
|
154
158
|
console.log('');
|
|
155
159
|
console.log(style.cyan(bannerLine('=')));
|
|
@@ -258,10 +262,27 @@ function printNext(result, options = {}) {
|
|
|
258
262
|
});
|
|
259
263
|
}
|
|
260
264
|
|
|
265
|
+
if (notableContracts.length > 0) {
|
|
266
|
+
console.log('');
|
|
267
|
+
console.log(style.bold('Contract Diffs'));
|
|
268
|
+
for (const item of notableContracts.slice(0, 8)) {
|
|
269
|
+
const tone = item.status === 'drifted'
|
|
270
|
+
? style.red(item.status.toUpperCase())
|
|
271
|
+
: (item.status === 'updated' ? style.yellow(item.status.toUpperCase()) : style.green(item.status.toUpperCase()));
|
|
272
|
+
console.log(` ${tone} ${item.fullName} :: ${item.name}`);
|
|
273
|
+
console.log(` ${style.dim(item.contractFile)}`);
|
|
274
|
+
const summaryLine = formatContractDiffSummary(item.diff);
|
|
275
|
+
if (summaryLine) {
|
|
276
|
+
console.log(` ${style.dim(summaryLine)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
261
281
|
console.log('');
|
|
262
282
|
console.log(style.bold('Agent Loop Commands'));
|
|
263
283
|
console.log(` ${style.cyan('rerun failed:')} npx themis test --rerun-failed --reporter next`);
|
|
264
284
|
console.log(` ${style.cyan('targeted rerun:')} npx themis test --match \"<regex>\" --reporter next`);
|
|
285
|
+
console.log(` ${style.cyan('update contracts:')} npx themis test --update-contracts --match \"<regex>\" --reporter next`);
|
|
265
286
|
console.log(style.cyan(bannerLine('=')));
|
|
266
287
|
}
|
|
267
288
|
|
|
@@ -374,6 +395,36 @@ function collectAgentFailures(files) {
|
|
|
374
395
|
return failures;
|
|
375
396
|
}
|
|
376
397
|
|
|
398
|
+
function collectContractItems(files) {
|
|
399
|
+
const items = [];
|
|
400
|
+
for (const file of files || []) {
|
|
401
|
+
for (const contract of file.contracts || []) {
|
|
402
|
+
items.push({
|
|
403
|
+
...contract,
|
|
404
|
+
file: file.file
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return items;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function formatContractDiffSummary(diff) {
|
|
412
|
+
if (!diff) {
|
|
413
|
+
return '';
|
|
414
|
+
}
|
|
415
|
+
const parts = [];
|
|
416
|
+
if (Array.isArray(diff.changed) && diff.changed.length > 0) {
|
|
417
|
+
parts.push(`${diff.changed.length} changed${diff.changed[0] ? ` (${diff.changed[0].path})` : ''}`);
|
|
418
|
+
}
|
|
419
|
+
if (Array.isArray(diff.added) && diff.added.length > 0) {
|
|
420
|
+
parts.push(`${diff.added.length} added${diff.added[0] ? ` (${diff.added[0].path})` : ''}`);
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(diff.removed) && diff.removed.length > 0) {
|
|
423
|
+
parts.push(`${diff.removed.length} removed${diff.removed[0] ? ` (${diff.removed[0].path})` : ''}`);
|
|
424
|
+
}
|
|
425
|
+
return parts.join(', ');
|
|
426
|
+
}
|
|
427
|
+
|
|
377
428
|
function clusterFailures(failures) {
|
|
378
429
|
const byFingerprint = new Map();
|
|
379
430
|
|
|
@@ -464,6 +515,8 @@ function renderHtmlReport(result, options = {}) {
|
|
|
464
515
|
.slice(0, 8);
|
|
465
516
|
|
|
466
517
|
const failures = allTests.filter((test) => test.status === 'failed');
|
|
518
|
+
const contractItems = collectContractItems(files);
|
|
519
|
+
const notableContracts = contractItems.filter((item) => item.status !== 'unchanged');
|
|
467
520
|
const failureClusters = clusterFailures(collectAgentFailures(files));
|
|
468
521
|
const gateState = stability && stability.runs > 1
|
|
469
522
|
? (Number(stability.summary?.unstable || 0) === 0 && Number(stability.summary?.stableFail || 0) === 0 ? 'stable' : 'unstable')
|
|
@@ -633,6 +686,11 @@ function renderHtmlReport(result, options = {}) {
|
|
|
633
686
|
'<h2>Quick Actions</h2>' +
|
|
634
687
|
'<div class="action-grid">' +
|
|
635
688
|
[
|
|
689
|
+
{
|
|
690
|
+
title: 'Update Contracts',
|
|
691
|
+
description: 'Accept reviewed contract changes for a narrow slice of the suite.',
|
|
692
|
+
command: 'npx themis test --update-contracts --match "<regex>" --reporter html'
|
|
693
|
+
},
|
|
636
694
|
{
|
|
637
695
|
title: failures.length > 0 ? 'Rerun Failed' : 'Rerun Workflow',
|
|
638
696
|
description: failures.length > 0
|
|
@@ -697,6 +755,24 @@ function renderHtmlReport(result, options = {}) {
|
|
|
697
755
|
'</div>' +
|
|
698
756
|
'</section>'
|
|
699
757
|
);
|
|
758
|
+
const contractDiffPanel = notableContracts.length > 0
|
|
759
|
+
? (
|
|
760
|
+
'<section class="panel insight-panel" id="contracts">' +
|
|
761
|
+
'<h2>Contract Diffs</h2>' +
|
|
762
|
+
'<div class="cluster-list">' +
|
|
763
|
+
notableContracts.slice(0, 10).map((item) => {
|
|
764
|
+
return (
|
|
765
|
+
'<article class="cluster-item">' +
|
|
766
|
+
`<div class="cluster-fingerprint">${escapeHtml(item.status.toUpperCase())}</div>` +
|
|
767
|
+
`<div class="cluster-message">${escapeHtml(`${item.fullName} :: ${item.name}`)}</div>` +
|
|
768
|
+
`<div class="cluster-count">${escapeHtml(formatContractDiffSummary(item.diff) || item.contractFile)}</div>` +
|
|
769
|
+
'</article>'
|
|
770
|
+
);
|
|
771
|
+
}).join('\n') +
|
|
772
|
+
'</div>' +
|
|
773
|
+
'</section>'
|
|
774
|
+
)
|
|
775
|
+
: '';
|
|
700
776
|
const stabilityPanel = stabilitySection
|
|
701
777
|
? stabilitySection.replace('<section class="panel">', '<section class="panel insight-panel" id="stability">')
|
|
702
778
|
: '';
|
|
@@ -706,7 +782,7 @@ function renderHtmlReport(result, options = {}) {
|
|
|
706
782
|
const slowestPanel = slowestSection
|
|
707
783
|
? slowestSection.replace('<section class="panel">', '<section class="panel insight-panel" id="slowest">')
|
|
708
784
|
: '';
|
|
709
|
-
const insightsGrid = [stabilityPanel, failureClusterPanel, slowestPanel].filter(Boolean).join('\n');
|
|
785
|
+
const insightsGrid = [stabilityPanel, failureClusterPanel, contractDiffPanel, slowestPanel].filter(Boolean).join('\n');
|
|
710
786
|
const triageGrid = [quickActionsSection, focusPanel].join('\n');
|
|
711
787
|
|
|
712
788
|
return `<!doctype html>
|
package/src/runner.js
CHANGED
|
@@ -80,6 +80,7 @@ function runFileInWorker(file, options = {}) {
|
|
|
80
80
|
match: options.match || null,
|
|
81
81
|
allowedFullNames: Array.isArray(options.allowedFullNames) ? options.allowedFullNames : null,
|
|
82
82
|
noMemes: Boolean(options.noMemes),
|
|
83
|
+
updateContracts: Boolean(options.updateContracts),
|
|
83
84
|
cwd: options.cwd || process.cwd(),
|
|
84
85
|
environment: options.environment || 'node',
|
|
85
86
|
setupFiles: Array.isArray(options.setupFiles) ? options.setupFiles : [],
|
|
@@ -196,6 +197,7 @@ function buildInProcessCacheKey(file, options) {
|
|
|
196
197
|
match: options.match || null,
|
|
197
198
|
allowedFullNames: Array.isArray(options.allowedFullNames) ? options.allowedFullNames : null,
|
|
198
199
|
noMemes: Boolean(options.noMemes),
|
|
200
|
+
updateContracts: Boolean(options.updateContracts),
|
|
199
201
|
cwd: options.cwd || process.cwd(),
|
|
200
202
|
environment: options.environment || 'node',
|
|
201
203
|
setupFiles: Array.isArray(options.setupFiles) ? options.setupFiles : [],
|