@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.
@@ -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 report = buildMigrationReport(projectRoot, source, scan.matches, rewriteSummary);
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(projectRoot, source, matches, rewriteSummary = { rewrittenFiles: [], rewrittenImports: 0 }) {
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 : [],