@vitronai/themis 0.1.15 → 1.2.2

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,153 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Themis Claude Code PostToolUse hook.
4
+ //
5
+ // This script is invoked by Claude Code after Edit/Write/MultiEdit tool calls.
6
+ // It reads the tool input from stdin, decides whether the edit is worth
7
+ // re-running tests for, and if so runs `themis test --reporter agent` (using
8
+ // --rerun-failed when there is a prior failed-tests artifact). When tests
9
+ // fail, the JSON failure payload is written to stderr and the script exits
10
+ // with code 2 — Claude Code feeds that back into the model so it can fix
11
+ // failures using the structured `failures[].cluster` and
12
+ // `failures[].repairHints` fields.
13
+ //
14
+ // Wire it up in `.claude/settings.json`:
15
+ //
16
+ // {
17
+ // "hooks": {
18
+ // "PostToolUse": [
19
+ // {
20
+ // "matcher": "Edit|Write|MultiEdit",
21
+ // "hooks": [
22
+ // { "type": "command", "command": "node node_modules/@vitronai/themis/scripts/claude-hook.js" }
23
+ // ]
24
+ // }
25
+ // ]
26
+ // }
27
+ // }
28
+ //
29
+ // Disable temporarily by setting THEMIS_HOOK_DISABLED=1 in your environment.
30
+ // Disable permanently by removing the entry from .claude/settings.json.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const { spawnSync } = require('child_process');
37
+
38
+ const SOURCE_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
39
+ const IGNORED_PATH_SEGMENTS = ['.themis', '__themis__', 'node_modules', '.git'];
40
+
41
+ function exitSilent() {
42
+ process.exit(0);
43
+ }
44
+
45
+ function readStdinSync() {
46
+ try {
47
+ return fs.readFileSync(0, 'utf8');
48
+ } catch (_err) {
49
+ return '';
50
+ }
51
+ }
52
+
53
+ function parsePayload(raw) {
54
+ if (!raw) return null;
55
+ try {
56
+ return JSON.parse(raw);
57
+ } catch (_err) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function extractFilePath(payload) {
63
+ if (!payload || typeof payload !== 'object') return null;
64
+ const input = payload.tool_input;
65
+ if (!input || typeof input !== 'object') return null;
66
+ if (typeof input.file_path === 'string') return input.file_path;
67
+ // MultiEdit nests edits in an array but still uses the top-level file_path.
68
+ return null;
69
+ }
70
+
71
+ function isWorthRerunning(filePath, cwd) {
72
+ if (!filePath) return false;
73
+ const normalized = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
74
+ const relative = path.relative(cwd, normalized);
75
+ if (relative.startsWith('..')) return false;
76
+
77
+ const segments = relative.split(path.sep);
78
+ for (const segment of segments) {
79
+ if (IGNORED_PATH_SEGMENTS.includes(segment)) return false;
80
+ }
81
+
82
+ const ext = path.extname(normalized).toLowerCase();
83
+ if (!SOURCE_EXT.has(ext)) return false;
84
+
85
+ return true;
86
+ }
87
+
88
+ function hasFailedTestsArtifact(cwd) {
89
+ const candidates = [
90
+ path.join(cwd, '.themis', 'runs', 'failed-tests.json'),
91
+ path.join(cwd, '.themis', 'failed-tests.json')
92
+ ];
93
+ return candidates.some((candidate) => fs.existsSync(candidate));
94
+ }
95
+
96
+ function findThemisBin(cwd) {
97
+ // Prefer the locally installed bin so the hook does not depend on `npx`
98
+ // resolution behavior or network state.
99
+ const localBin = path.join(cwd, 'node_modules', '.bin', 'themis');
100
+ if (fs.existsSync(localBin)) return { command: localBin, args: [] };
101
+ const localScript = path.join(cwd, 'node_modules', '@vitronai', 'themis', 'bin', 'themis.js');
102
+ if (fs.existsSync(localScript)) return { command: process.execPath, args: [localScript] };
103
+ return { command: 'npx', args: ['--no-install', 'themis'] };
104
+ }
105
+
106
+ function main() {
107
+ if (process.env.THEMIS_HOOK_DISABLED) exitSilent();
108
+
109
+ const raw = readStdinSync();
110
+ const payload = parsePayload(raw);
111
+ const cwd = (payload && typeof payload.cwd === 'string' && payload.cwd) || process.cwd();
112
+ const filePath = extractFilePath(payload);
113
+
114
+ if (!isWorthRerunning(filePath, cwd)) exitSilent();
115
+
116
+ const themis = findThemisBin(cwd);
117
+ const args = [...themis.args, 'test', '--reporter', 'agent'];
118
+ if (hasFailedTestsArtifact(cwd)) args.push('--rerun-failed');
119
+
120
+ const result = spawnSync(themis.command, args, {
121
+ cwd,
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ env: process.env,
124
+ maxBuffer: 32 * 1024 * 1024
125
+ });
126
+
127
+ if (result.error) {
128
+ // Hook itself failed (binary not found, etc). Stay silent rather than
129
+ // blocking the user's edit loop on infrastructure problems.
130
+ exitSilent();
131
+ }
132
+
133
+ const stdout = (result.stdout || Buffer.alloc(0)).toString('utf8');
134
+ const stderr = (result.stderr || Buffer.alloc(0)).toString('utf8');
135
+
136
+ if (result.status === 0) {
137
+ exitSilent();
138
+ }
139
+
140
+ // Tests failed. Surface the agent JSON payload (or stderr fallback) to
141
+ // Claude via stderr + exit 2 so it lands in the model's context.
142
+ process.stderr.write('Themis tests failed after edit. Use failures[].cluster and failures[].repairHints below to fix:\n');
143
+ if (stdout.trim().length > 0) {
144
+ process.stderr.write(stdout);
145
+ if (!stdout.endsWith('\n')) process.stderr.write('\n');
146
+ } else if (stderr.trim().length > 0) {
147
+ process.stderr.write(stderr);
148
+ if (!stderr.endsWith('\n')) process.stderr.write('\n');
149
+ }
150
+ process.exit(2);
151
+ }
152
+
153
+ main();
package/src/cli.js CHANGED
@@ -24,13 +24,54 @@ async function main(argv) {
24
24
  const initFlags = parseInitFlags(argv.slice(1));
25
25
  const initResult = runInit(cwd, initFlags);
26
26
  console.log('Themis initialized. Next: npx themis generate <source-root> && npx themis test');
27
- if (initFlags.agents) {
28
- if (initResult && initResult.path && initResult.created) {
29
- console.log(`Agents: created ${formatCliPath(cwd, initResult.path)} from the Themis downstream template.`);
27
+ if (initResult.autoDetected) {
28
+ const detected = [];
29
+ if (initResult.agents) detected.push('AGENTS.md');
30
+ if (initResult.claudeCode) detected.push('Claude Code');
31
+ if (initResult.cursor) detected.push('Cursor');
32
+ if (detected.length > 0) {
33
+ console.log(`Auto-detected: ${detected.join(', ')}`);
34
+ }
35
+ }
36
+ if (initFlags.agents || initResult.agents) {
37
+ const agents = initResult.agents;
38
+ if (agents && agents.created) {
39
+ console.log(`Agents: created ${formatCliPath(cwd, agents.path)} from the Themis downstream template.`);
30
40
  } else {
31
41
  console.log('Agents: skipped AGENTS.md scaffold because one already exists.');
32
42
  }
33
43
  }
44
+ if (initResult.cursor) {
45
+ const cur = initResult.cursor;
46
+ if (cur.created) {
47
+ console.log(`Cursor: created ${formatCliPath(cwd, cur.path)} from the Themis Cursor template.`);
48
+ } else if (cur.appended) {
49
+ console.log(`Cursor: appended Themis section to ${formatCliPath(cwd, cur.path)}.`);
50
+ } else {
51
+ console.log(`Cursor: skipped .cursorrules update (already mentions @vitronai/themis).`);
52
+ }
53
+ }
54
+ if (initResult.claudeCode) {
55
+ const cc = initResult.claudeCode;
56
+ if (cc.claudeMd.created) {
57
+ console.log(`Claude Code: created ${formatCliPath(cwd, cc.claudeMd.path)} from the Themis Claude template.`);
58
+ } else if (cc.claudeMd.appended) {
59
+ console.log(`Claude Code: appended Themis section to ${formatCliPath(cwd, cc.claudeMd.path)}.`);
60
+ } else {
61
+ console.log(`Claude Code: skipped CLAUDE.md update (already mentions @vitronai/themis).`);
62
+ }
63
+ if (cc.skill.created) {
64
+ console.log(`Claude Code: installed skill at ${formatCliPath(cwd, cc.skill.path)}.`);
65
+ } else {
66
+ console.log(`Claude Code: skipped skill (${formatCliPath(cwd, cc.skill.path)} already exists).`);
67
+ }
68
+ if (cc.commands.written.length > 0) {
69
+ console.log(`Claude Code: installed ${cc.commands.written.length} slash command(s) under ${formatCliPath(cwd, cc.commands.dir)}.`);
70
+ }
71
+ if (cc.commands.skipped.length > 0) {
72
+ console.log(`Claude Code: skipped ${cc.commands.skipped.length} existing slash command file(s).`);
73
+ }
74
+ }
34
75
  return;
35
76
  }
36
77
 
@@ -95,6 +136,17 @@ async function main(argv) {
95
136
  if (result.convertedFiles && result.convertedFiles.length > 0) {
96
137
  console.log(`Codemods: converted ${result.convertedFiles.length} file(s) to Themis-native patterns.`);
97
138
  }
139
+ if (result.assist) {
140
+ console.log(`Assistant: analyzed ${result.assistSummary.analyzedFiles} migrated file(s).`);
141
+ if (result.assistSummary.findings.length > 0) {
142
+ console.log(
143
+ `Assistant: flagged ${result.assistSummary.findings.length} manual follow-up item(s) across ${result.assistSummary.unresolvedFiles.length} file(s).`
144
+ );
145
+ } else {
146
+ console.log('Assistant: no unsupported Jest/Vitest-only patterns detected in migrated files.');
147
+ }
148
+ console.log(`Report: ${formatCliPath(cwd, result.reportPath)}`);
149
+ }
98
150
  console.log('Runtime compatibility is enabled for @jest/globals, vitest, and @testing-library/react imports.');
99
151
  console.log('Next: run npx themis test or npm run test:themis');
100
152
  return;
@@ -500,7 +552,8 @@ function parseMigrateFlags(args) {
500
552
  const flags = {
501
553
  source: args[0],
502
554
  rewriteImports: false,
503
- convert: false
555
+ convert: false,
556
+ assist: false
504
557
  };
505
558
 
506
559
  for (let i = 1; i < args.length; i += 1) {
@@ -511,6 +564,16 @@ function parseMigrateFlags(args) {
511
564
  }
512
565
  if (token === '--convert') {
513
566
  flags.convert = true;
567
+ continue;
568
+ }
569
+ if (token === '--assist') {
570
+ flags.assist = true;
571
+ flags.rewriteImports = true;
572
+ flags.convert = true;
573
+ continue;
574
+ }
575
+ if (token.startsWith('-')) {
576
+ throw new Error(`Unsupported migrate option: ${token}`);
514
577
  }
515
578
  }
516
579
 
@@ -519,7 +582,9 @@ function parseMigrateFlags(args) {
519
582
 
520
583
  function parseInitFlags(args) {
521
584
  const flags = {
522
- agents: false
585
+ agents: false,
586
+ claudeCode: false,
587
+ cursor: false
523
588
  };
524
589
 
525
590
  for (let i = 0; i < args.length; i += 1) {
@@ -528,6 +593,14 @@ function parseInitFlags(args) {
528
593
  flags.agents = true;
529
594
  continue;
530
595
  }
596
+ if (token === '--claude-code' || token === '--claude') {
597
+ flags.claudeCode = true;
598
+ continue;
599
+ }
600
+ if (token === '--cursor') {
601
+ flags.cursor = true;
602
+ continue;
603
+ }
531
604
  if (token.startsWith('-')) {
532
605
  throw new Error(`Unsupported init option: ${token}`);
533
606
  }
@@ -741,7 +814,7 @@ function printUsage() {
741
814
  console.log(' generate [path] Scan source files and generate Themis contract tests');
742
815
  console.log(' Options: [--json] [--plan] [--output path] [--files a,b] [--match-source regex] [--match-export regex] [--scenario name] [--min-confidence level] [--require-confidence level] [--include regex] [--exclude regex] [--review] [--update] [--clean] [--changed] [--force] [--strict] [--write-hints] [--fail-on-skips] [--fail-on-conflicts]');
743
816
  console.log(' scan [path] Alias for generate');
744
- console.log(' migrate <jest|vitest> [--rewrite-imports] [--convert] Scaffold an incremental migration bridge for existing suites');
817
+ console.log(' migrate <jest|vitest> [--rewrite-imports] [--convert] [--assist] Scaffold an incremental migration bridge for existing suites');
745
818
  console.log(' test [--json] [--agent] [--next] [--reporter spec|next|json|agent|html] [--workers N] [--stability N] [--environment node|jsdom] [--isolation worker|in-process] [--cache] [--update-contracts] [--fix] [-w|--watch] [--html-output path] [--match regex] [--rerun-failed] [--no-memes] [--lexicon classic|themis]');
746
819
  }
747
820
 
package/src/init.js CHANGED
@@ -3,17 +3,36 @@ const path = require('path');
3
3
  const { initConfig } = require('./config');
4
4
  const { ensureGitignoreEntries } = require('./gitignore');
5
5
 
6
- const AGENTS_TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'AGENTS.themis.md');
6
+ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
7
+ const AGENTS_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'AGENTS.themis.md');
8
+ const CLAUDE_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'CLAUDE.themis.md');
9
+ const CLAUDE_SKILL_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'claude-skill', 'SKILL.md');
10
+ const CLAUDE_COMMANDS_TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'claude-commands');
11
+ const CURSOR_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'cursorrules.themis.md');
7
12
 
8
13
  function runInit(cwd, options = {}) {
9
14
  initConfig(cwd);
10
15
  ensureGitignoreEntries(cwd, ['.themis/', '__themis__/reports/', '__themis__/shims/']);
11
16
 
12
- if (options.agents) {
13
- return ensureAgentsTemplate(cwd);
17
+ const hasExplicitFlags = options.agents || options.claudeCode || options.cursor;
18
+ const detected = hasExplicitFlags ? options : detectAgents(cwd, options);
19
+
20
+ const result = {};
21
+
22
+ if (detected.agents) {
23
+ result.agents = ensureAgentsTemplate(cwd);
24
+ }
25
+
26
+ if (detected.claudeCode) {
27
+ result.claudeCode = ensureClaudeCodeAssets(cwd);
28
+ }
29
+
30
+ if (detected.cursor) {
31
+ result.cursor = writeCursorRules(cwd);
14
32
  }
15
33
 
16
- return null;
34
+ result.autoDetected = !hasExplicitFlags;
35
+ return result;
17
36
  }
18
37
 
19
38
  function ensureAgentsTemplate(cwd) {
@@ -33,6 +52,105 @@ function ensureAgentsTemplate(cwd) {
33
52
  };
34
53
  }
35
54
 
55
+ function ensureClaudeCodeAssets(cwd) {
56
+ const result = {
57
+ claudeMd: writeClaudeMd(cwd),
58
+ skill: writeClaudeSkill(cwd),
59
+ commands: writeClaudeCommands(cwd)
60
+ };
61
+ return result;
62
+ }
63
+
64
+ function writeClaudeMd(cwd) {
65
+ const targetPath = path.join(cwd, 'CLAUDE.md');
66
+ const source = fs.readFileSync(CLAUDE_TEMPLATE_PATH, 'utf8');
67
+
68
+ if (!fs.existsSync(targetPath)) {
69
+ fs.writeFileSync(targetPath, source, 'utf8');
70
+ return { path: targetPath, created: true, appended: false };
71
+ }
72
+
73
+ const existing = fs.readFileSync(targetPath, 'utf8');
74
+ if (existing.includes('@vitronai/themis')) {
75
+ return { path: targetPath, created: false, appended: false };
76
+ }
77
+
78
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
79
+ fs.writeFileSync(targetPath, existing + separator + source, 'utf8');
80
+ return { path: targetPath, created: false, appended: true };
81
+ }
82
+
83
+ function writeClaudeSkill(cwd) {
84
+ const targetDir = path.join(cwd, '.claude', 'skills', 'themis');
85
+ const targetPath = path.join(targetDir, 'SKILL.md');
86
+ if (fs.existsSync(targetPath)) {
87
+ return { path: targetPath, created: false };
88
+ }
89
+ fs.mkdirSync(targetDir, { recursive: true });
90
+ const source = fs.readFileSync(CLAUDE_SKILL_TEMPLATE_PATH, 'utf8');
91
+ fs.writeFileSync(targetPath, source, 'utf8');
92
+ return { path: targetPath, created: true };
93
+ }
94
+
95
+ function writeClaudeCommands(cwd) {
96
+ const targetDir = path.join(cwd, '.claude', 'commands');
97
+ fs.mkdirSync(targetDir, { recursive: true });
98
+
99
+ const entries = fs.readdirSync(CLAUDE_COMMANDS_TEMPLATE_DIR);
100
+ const written = [];
101
+ const skipped = [];
102
+
103
+ for (const entry of entries) {
104
+ if (!entry.endsWith('.md')) continue;
105
+ const sourcePath = path.join(CLAUDE_COMMANDS_TEMPLATE_DIR, entry);
106
+ const targetPath = path.join(targetDir, entry);
107
+ if (fs.existsSync(targetPath)) {
108
+ skipped.push(targetPath);
109
+ continue;
110
+ }
111
+ const source = fs.readFileSync(sourcePath, 'utf8');
112
+ fs.writeFileSync(targetPath, source, 'utf8');
113
+ written.push(targetPath);
114
+ }
115
+
116
+ return { dir: targetDir, written, skipped };
117
+ }
118
+
119
+ function detectAgents(cwd) {
120
+ const flags = { agents: true, claudeCode: false, cursor: false };
121
+
122
+ // Claude Code: .claude/ dir or CLAUDE.md exists
123
+ if (fs.existsSync(path.join(cwd, '.claude')) || fs.existsSync(path.join(cwd, 'CLAUDE.md'))) {
124
+ flags.claudeCode = true;
125
+ }
126
+
127
+ // Cursor: .cursorrules or .cursor/ dir exists
128
+ if (fs.existsSync(path.join(cwd, '.cursorrules')) || fs.existsSync(path.join(cwd, '.cursor'))) {
129
+ flags.cursor = true;
130
+ }
131
+
132
+ return flags;
133
+ }
134
+
135
+ function writeCursorRules(cwd) {
136
+ const targetPath = path.join(cwd, '.cursorrules');
137
+ const source = fs.readFileSync(CURSOR_TEMPLATE_PATH, 'utf8');
138
+
139
+ if (!fs.existsSync(targetPath)) {
140
+ fs.writeFileSync(targetPath, source, 'utf8');
141
+ return { path: targetPath, created: true, appended: false };
142
+ }
143
+
144
+ const existing = fs.readFileSync(targetPath, 'utf8');
145
+ if (existing.includes('@vitronai/themis')) {
146
+ return { path: targetPath, created: false, appended: false };
147
+ }
148
+
149
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
150
+ fs.writeFileSync(targetPath, existing + separator + source, 'utf8');
151
+ return { path: targetPath, created: false, appended: true };
152
+ }
153
+
36
154
  module.exports = {
37
155
  runInit
38
156
  };
package/src/migrate.js CHANGED
@@ -10,6 +10,40 @@ const THEMIS_COMPAT_FILE = 'themis.compat.js';
10
10
  const MIGRATION_REPORT_FILE = ARTIFACT_RELATIVE_PATHS.migrationReport;
11
11
  const SCANNABLE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
12
12
  const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.themis']);
13
+ const MIGRATION_ASSIST_PATTERNS = Object.freeze([
14
+ {
15
+ id: 'remaining-framework-import',
16
+ category: 'remaining-framework-import',
17
+ severity: 'warning',
18
+ pattern: /(?:import\s+[^;]*?from\s+['"](?:@jest\/globals|vitest|@testing-library\/react)['"]|import\s*\(\s*['"](?:@jest\/globals|vitest|@testing-library\/react)['"]\s*\)|require\(\s*['"](?:@jest\/globals|vitest|@testing-library\/react)['"]\s*\))/,
19
+ message: 'Framework-specific imports are still present after migration scaffolding.',
20
+ suggestion: 'Rewrite or remove remaining framework imports so the suite only depends on Themis-compatible entry points.'
21
+ },
22
+ {
23
+ id: 'unsupported-helper',
24
+ category: 'unsupported-helper',
25
+ severity: 'warning',
26
+ pattern: /\b(?:jest|vi)\.(?:mocked|doMock|dontMock|setMock|requireActual|requireMock|createMockFromModule|isolateModules|isolateModulesAsync|unstable_mockModule|importActual|importMock)\s*\(/,
27
+ message: 'Unsupported Jest/Vitest helper detected.',
28
+ suggestion: 'Replace the helper with an explicit Themis mock, fixture, or project-local test utility before relying on the migrated suite.'
29
+ },
30
+ {
31
+ id: 'async-matcher-chain',
32
+ category: 'async-matcher-chain',
33
+ severity: 'warning',
34
+ pattern: /\.\s*(?:resolves|rejects)\b/,
35
+ message: 'Promise matcher chains remain in the migrated file.',
36
+ suggestion: 'Rewrite promise assertions to explicit await-based checks so they run under Themis without framework-specific matcher chaining.'
37
+ },
38
+ {
39
+ id: 'focused-alias',
40
+ category: 'focused-alias',
41
+ severity: 'warning',
42
+ pattern: /\b(?:fit|xit|fdescribe|xdescribe)\s*\(/,
43
+ message: 'Focused or excluded Jest/Vitest aliases remain in the migrated file.',
44
+ suggestion: 'Replace focused aliases with Themis-supported describe/test forms before running the migrated suite broadly.'
45
+ }
46
+ ]);
13
47
 
14
48
  function runMigrate(cwd, framework, options = {}) {
15
49
  const source = String(framework || '').trim().toLowerCase();
@@ -66,7 +100,14 @@ function runMigrate(cwd, framework, options = {}) {
66
100
  const conversionSummary = options.convert
67
101
  ? convertMigrationFiles(projectRoot, scan.matches)
68
102
  : { convertedFiles: [], convertedAssertions: 0, removedImports: 0 };
69
- const report = buildMigrationReport(projectRoot, source, scan.matches, rewriteSummary, conversionSummary);
103
+ const assistSummary = analyzeMigrationAssist(projectRoot, scan.matches, {
104
+ enabled: Boolean(options.assist)
105
+ });
106
+ const report = buildMigrationReport(projectRoot, source, scan.matches, rewriteSummary, conversionSummary, assistSummary, {
107
+ rewriteImports: Boolean(options.rewriteImports),
108
+ convert: Boolean(options.convert),
109
+ assist: Boolean(options.assist)
110
+ });
70
111
  fs.mkdirSync(path.dirname(reportPath), { recursive: true });
71
112
  fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
72
113
 
@@ -84,7 +125,9 @@ function runMigrate(cwd, framework, options = {}) {
84
125
  rewriteImports: Boolean(options.rewriteImports),
85
126
  rewrittenFiles: rewriteSummary.rewrittenFiles,
86
127
  convert: Boolean(options.convert),
87
- convertedFiles: conversionSummary.convertedFiles
128
+ convertedFiles: conversionSummary.convertedFiles,
129
+ assist: Boolean(options.assist),
130
+ assistSummary
88
131
  };
89
132
  }
90
133
 
@@ -170,12 +213,20 @@ function buildMigrationReport(
170
213
  source,
171
214
  matches,
172
215
  rewriteSummary = { rewrittenFiles: [], rewrittenImports: 0 },
173
- conversionSummary = { convertedFiles: [], convertedAssertions: 0, removedImports: 0 }
216
+ conversionSummary = { convertedFiles: [], convertedAssertions: 0, removedImports: 0 },
217
+ assistSummary = buildEmptyAssistSummary(false),
218
+ mode = { rewriteImports: false, convert: false, assist: false }
174
219
  ) {
220
+ const effectiveAssistSummary = normalizeAssistSummary(assistSummary, Boolean(mode.assist));
175
221
  return {
176
222
  schema: 'themis.migration.report.v1',
177
223
  source,
178
224
  createdAt: new Date().toISOString(),
225
+ mode: {
226
+ rewriteImports: Boolean(mode.rewriteImports),
227
+ convert: Boolean(mode.convert),
228
+ assist: Boolean(mode.assist)
229
+ },
179
230
  summary: {
180
231
  matchedFiles: matches.length,
181
232
  jestGlobals: matches.filter((entry) => entry.imports.includes('@jest/globals')).length,
@@ -185,11 +236,18 @@ function buildMigrationReport(
185
236
  rewrittenImports: Number(rewriteSummary.rewrittenImports || 0),
186
237
  convertedFiles: Array.isArray(conversionSummary.convertedFiles) ? conversionSummary.convertedFiles.length : 0,
187
238
  convertedAssertions: Number(conversionSummary.convertedAssertions || 0),
188
- removedImports: Number(conversionSummary.removedImports || 0)
239
+ removedImports: Number(conversionSummary.removedImports || 0),
240
+ assistedFiles: Number(effectiveAssistSummary.analyzedFiles || 0),
241
+ unresolvedFiles: Array.isArray(effectiveAssistSummary.unresolvedFiles) ? effectiveAssistSummary.unresolvedFiles.length : 0,
242
+ findings: Array.isArray(effectiveAssistSummary.findings) ? effectiveAssistSummary.findings.length : 0,
243
+ unsupportedPatterns: Number(effectiveAssistSummary.unsupportedPatterns || 0)
189
244
  },
190
245
  files: matches,
191
246
  nextActions: [
192
247
  'Run npx themis test to execute migrated suites under the Themis runtime.',
248
+ ...(effectiveAssistSummary.findings.length > 0
249
+ ? ['Resolve assistant findings in the migration report before relying on the migrated suite in CI.']
250
+ : []),
193
251
  'Replace any unsupported Jest/Vitest-only helpers with Themis built-ins or project setup utilities.',
194
252
  'Use npx themis generate src for source-driven unit-layer coverage alongside migrated suites.'
195
253
  ],
@@ -198,7 +256,71 @@ function buildMigrationReport(
198
256
  : [],
199
257
  conversions: Array.isArray(conversionSummary.convertedFiles)
200
258
  ? conversionSummary.convertedFiles
201
- : []
259
+ : [],
260
+ assistant: effectiveAssistSummary
261
+ };
262
+ }
263
+
264
+ function analyzeMigrationAssist(projectRoot, matches, options = {}) {
265
+ const enabled = Boolean(options.enabled);
266
+ if (!enabled) {
267
+ return buildEmptyAssistSummary(false);
268
+ }
269
+
270
+ const findings = [];
271
+ const unresolvedFiles = new Set();
272
+
273
+ for (const match of matches) {
274
+ const absoluteFile = path.join(projectRoot, match.file);
275
+ const sourceText = fs.readFileSync(absoluteFile, 'utf8');
276
+
277
+ for (const definition of MIGRATION_ASSIST_PATTERNS) {
278
+ if (!definition.pattern.test(sourceText)) {
279
+ continue;
280
+ }
281
+ unresolvedFiles.add(match.file);
282
+ findings.push({
283
+ file: match.file,
284
+ category: definition.category,
285
+ severity: definition.severity,
286
+ pattern: definition.id,
287
+ message: definition.message,
288
+ suggestion: definition.suggestion
289
+ });
290
+ }
291
+ }
292
+
293
+ return normalizeAssistSummary(
294
+ {
295
+ enabled: true,
296
+ analyzedFiles: matches.length,
297
+ findings,
298
+ unresolvedFiles: Array.from(unresolvedFiles).sort(),
299
+ unsupportedPatterns: findings.length
300
+ },
301
+ true
302
+ );
303
+ }
304
+
305
+ function buildEmptyAssistSummary(enabled) {
306
+ return {
307
+ enabled: Boolean(enabled),
308
+ analyzedFiles: 0,
309
+ findings: [],
310
+ unresolvedFiles: [],
311
+ unsupportedPatterns: 0
312
+ };
313
+ }
314
+
315
+ function normalizeAssistSummary(summary, enabled) {
316
+ const findings = Array.isArray(summary && summary.findings) ? summary.findings : [];
317
+ const unresolvedFiles = Array.isArray(summary && summary.unresolvedFiles) ? summary.unresolvedFiles : [];
318
+ return {
319
+ enabled: Boolean(enabled),
320
+ analyzedFiles: Number((summary && summary.analyzedFiles) || 0),
321
+ findings,
322
+ unresolvedFiles,
323
+ unsupportedPatterns: Number((summary && summary.unsupportedPatterns) || findings.length)
202
324
  };
203
325
  }
204
326
 
@@ -0,0 +1,43 @@
1
+ # Testing With Themis
2
+
3
+ This repository uses [`@vitronai/themis`](https://www.npmjs.com/package/@vitronai/themis) as its unit test framework. Themis is a drop-in alternative to Jest and Vitest, designed for AI coding agents like Claude Code: deterministic reruns, structured failure output, and machine-readable repair hints.
4
+
5
+ ## Commands
6
+
7
+ - Install: `npm install -D @vitronai/themis`
8
+ - Initialize: `npx themis init`
9
+ - Generate tests for a source tree: `npx themis generate src` (or `app` for Next App Router)
10
+ - Run tests: `npx themis test`
11
+ - Run tests and emit agent-readable output: `npx themis test --reporter agent`
12
+ - Re-run only failed tests: `npx themis test --rerun-failed`
13
+ - Migrate from Jest: `npx themis migrate jest` then `--rewrite-imports` then `--convert`
14
+ - Migrate from Vitest: `npx themis migrate vitest` then `--rewrite-imports` then `--convert`
15
+
16
+ ## When You Are Asked To Add Or Fix Tests
17
+
18
+ 1. If the repo has no tests yet, run `npx themis generate <source-root>` first. Generated tests land under `__themis__/tests` as `.generated.test.ts` (TS/TSX) or `.generated.test.js` (JS/JSX). Use them as a starting point — do not delete or rewrite them wholesale.
19
+ 2. Prefer `intent(...)` for behavior and workflow tests. Prefer `test(...)` for low-level unit checks. Use the four-phase shape: `context` → `run` → `verify` → `cleanup`.
20
+ 3. Run `npx themis test --reporter agent` and read the JSON output. Failure clusters and repair hints are structured — use them to drive your fix loop instead of re-reading raw stack traces.
21
+ 4. After fixing, re-run only the failing tests with `npx themis test --rerun-failed` before running the full suite.
22
+
23
+ ## When You Are Asked To Migrate From Jest Or Vitest
24
+
25
+ 1. Run `npx themis migrate jest` (or `vitest`) — this scaffolds compatibility, no rewrites yet.
26
+ 2. Run `npx themis migrate jest --rewrite-imports` to point imports at `themis.compat.js`.
27
+ 3. Run `npx themis migrate jest --convert` to apply codemods toward native Themis style.
28
+ 4. Run `npx themis migrate jest --assist` to get a structured findings report of files that still need manual follow-up. The report path is printed at the end of the command — read it before guessing what to fix.
29
+ 5. Run `npx themis test` after each step. Migration is incremental on purpose; do not try to convert the whole suite in one pass.
30
+
31
+ ## Things To Avoid
32
+
33
+ - Do not claim Themis is "not a unit test framework" — it is one.
34
+ - Do not create `tests/*.cjs` or `tests/*.setup.*` files just to load `.css`, `.png`, `.jpg`, `.svg`, or font/media imports. Themis handles these natively.
35
+ - Do not commit `.themis/`, `__themis__/reports/`, or `__themis__/shims/` — they are gitignored framework output.
36
+ - Do not rewrite generated tests under `__themis__/tests` by hand unless the user asks; treat them as Themis-managed.
37
+ - Do not reach for snapshot tests as the default — prefer deterministic assertions and `captureContract(...)` for contract-style checks.
38
+
39
+ ## Reference
40
+
41
+ - API reference: [`node_modules/@vitronai/themis/docs/api.md`](node_modules/@vitronai/themis/docs/api.md)
42
+ - Adoption guide: [`node_modules/@vitronai/themis/docs/agents-adoption.md`](node_modules/@vitronai/themis/docs/agents-adoption.md)
43
+ - Machine-readable manifest: [`node_modules/@vitronai/themis/themis.ai.json`](node_modules/@vitronai/themis/themis.ai.json)
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: Fix failing Themis tests using the agent reporter's repair hints
3
+ ---
4
+
5
+ Read the most recent Themis run output and fix the failures.
6
+
7
+ 1. Run `npx themis test --reporter agent` and capture the JSON output.
8
+ 2. Group the failures by `failures[].cluster`. Fixes within a cluster usually share a root cause — address the cluster, not each failure independently.
9
+ 3. For each cluster, read `failures[].repairHints` first. They are structured suggestions you can act on directly.
10
+ 4. Apply the smallest fix that addresses the root cause. Do not refactor surrounding code.
11
+ 5. Re-run with `npx themis test --rerun-failed` to confirm the cluster is fixed.
12
+ 6. Repeat for the next cluster. Only run the full suite once `--rerun-failed` is green.
13
+
14
+ If the user passed specific test names or files, scope the work to those: $ARGUMENTS