@ihelpee/prumo-plugin-node-jest 0.0.0-stage → 0.8.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gotardi and Prumo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @ihelpee/prumo-plugin-node-jest
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Jest & test suite audit plugin for Prumo. Inspects test ASTs for framework redundancies, mock tautologies, and performs surgical pruning of empty test suites.
4
+
5
+ ## Features
6
+
7
+ - **Redundant Guard Checks:** Identifies test cases asserting 401/403 HTTP status codes already proven by static invariants.
8
+ - **Redundant DTO Schema Checks:** Identifies tests checking 400 Bad Request on missing fields already guarded by `class-validator` / schema validation.
9
+ - **Tautological Mocks:** Detects circular mock verification (`jest.spyOn(...).toHaveBeenCalled()`) that only tests the mock setup.
10
+ - **DI Boot Checks:** Identifies boilerplate `toBeDefined()` checks duplicated across unit tests.
11
+ - **Surgical AST Prune:** Prunes redundant test blocks cleanly and automatically deletes empty test suites and orphan test files.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add -D @ihelpee/prumo-plugin-node-jest
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ In your `prumo.config.ts`:
22
+
23
+ ```typescript
24
+ import { defineConfig } from '@ihelpee/prumo';
25
+ import jest from '@ihelpee/prumo-plugin-node-jest';
26
+
27
+ export default defineConfig({
28
+ plugins: [
29
+ jest(),
30
+ ],
31
+ });
32
+ ```
33
+
34
+ Or run via CLI:
35
+
36
+ ```bash
37
+ npx prumo plugin node-jest
38
+ npx prumo test-audit --prune all
39
+ ```
40
+
41
+ ## Author & Maintainer
42
+
43
+ **Alex Gotardi**
44
+ - 📧 Email: [alexgotardi@gmail.com](mailto:alexgotardi@gmail.com)
45
+ - 🐙 GitHub: [@alexgotardi](https://github.com/alexgotardi)
46
+ - 💼 LinkedIn: [linkedin.com/in/alexgotardi](https://linkedin.com/in/alexgotardi)
47
+
48
+ ## License
49
+
50
+ MIT © Alex Gotardi and Prumo contributors.
@@ -0,0 +1,69 @@
1
+ import { type CallExpression } from 'ts-morph';
2
+ import { type PrumoPlugin } from '@ihelpee/prumo/invariants';
3
+ export type RedundantTestCategory = 'REDUNDANT_GUARD_CHECK' | 'REDUNDANT_DTO_SCHEMA_CHECK' | 'TAUTOLOGICAL_MOCK_CHECK' | 'REDUNDANT_DI_BOOT_CHECK';
4
+ export interface RedundantTestCandidate {
5
+ id: string;
6
+ file: string;
7
+ line: number;
8
+ testName: string;
9
+ category: RedundantTestCategory;
10
+ testType: 'feature' | 'unit';
11
+ estimatedSecondsSaved: number;
12
+ reason: string;
13
+ snippet: string;
14
+ }
15
+ export interface TestAuditReport {
16
+ scannedFiles: number;
17
+ totalTestsFound: number;
18
+ redundantTestsFound: number;
19
+ estimatedTotalSecondsSaved: number;
20
+ byCategory: Record<RedundantTestCategory, number>;
21
+ byType: {
22
+ feature: number;
23
+ unit: number;
24
+ };
25
+ candidates: RedundantTestCandidate[];
26
+ }
27
+ export interface PruneResult {
28
+ prunedCount: number;
29
+ estimatedSecondsSaved: number;
30
+ prunedFiles: string[];
31
+ removedFiles: string[];
32
+ prunedCandidates: RedundantTestCandidate[];
33
+ }
34
+ export declare const DEFAULT_TEST_PATTERNS: string[];
35
+ /**
36
+ * Determina se o arquivo de teste é um Feature/Integration test pesado
37
+ * ou um Unit test rápido.
38
+ */
39
+ export declare function classifyTestType(filePath: string, fileContent: string): 'feature' | 'unit';
40
+ export declare function isTestCall(call: CallExpression): boolean;
41
+ export declare function isSuiteCall(call: CallExpression): boolean;
42
+ export declare function suiteHasTests(call: CallExpression): boolean;
43
+ export declare function removeFileAndEmptyParents(absPath: string, root: string): Promise<void>;
44
+ export declare function extractTestName(call: CallExpression): string;
45
+ export interface TestAnalysis {
46
+ isRedundant: boolean;
47
+ category?: RedundantTestCategory;
48
+ reason?: string;
49
+ }
50
+ export declare function analyzeTestBlock(call: CallExpression): TestAnalysis;
51
+ /**
52
+ * Realiza auditoria estática completa da suíte de testes.
53
+ */
54
+ export declare function auditTestSuites(root: string, patterns?: string[], ignore?: string[], knownTestFiles?: string[]): Promise<TestAuditReport>;
55
+ /**
56
+ * Poda com precisão cirúrgica de AST os testes redundantes selecionados (ou todos).
57
+ * Elimina automaticamente suítes vazias e arquivos de teste órfãos.
58
+ */
59
+ export declare function pruneRedundantTests(root: string, testIds: string[] | 'all', patterns?: string[]): Promise<PruneResult>;
60
+ export interface JestPluginOptions {
61
+ patterns?: string[];
62
+ pruneEmptySuites?: boolean;
63
+ }
64
+ /**
65
+ * Definição canônica do plugin Jest para Prumo.
66
+ */
67
+ export declare function jestPlugin(options?: JestPluginOptions): PrumoPlugin;
68
+ export default jestPlugin;
69
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,409 @@
1
+ import { Project, Node, SyntaxKind } from 'ts-morph';
2
+ import { walk, norm } from '@ihelpee/prumo/core';
3
+ import { definePlugin } from '@ihelpee/prumo/invariants';
4
+ import { resolve } from 'node:path';
5
+ function toRelPath(root, abs) {
6
+ const nRoot = norm(root).replace(/\/+$/, '');
7
+ const nAbs = norm(abs);
8
+ return nAbs.startsWith(nRoot + '/') ? nAbs.slice(nRoot.length + 1) : nAbs;
9
+ }
10
+ export const DEFAULT_TEST_PATTERNS = [
11
+ '**/*.spec.ts',
12
+ '**/*.test.ts',
13
+ '**/*.e2e-spec.ts',
14
+ 'test/**/*.ts',
15
+ 'tests/**/*.ts',
16
+ ];
17
+ const FEATURE_TEST_HEURISTICS = [
18
+ 'supertest',
19
+ '@nestjs/testing',
20
+ 'Test.createTestingModule',
21
+ 'global.events',
22
+ 'inject(',
23
+ 'prisma',
24
+ 'typeorm',
25
+ 'pg',
26
+ 'ioredis',
27
+ ];
28
+ const FEATURE_TIME_SECONDS = 3.5;
29
+ const UNIT_TIME_SECONDS = 0.05;
30
+ /**
31
+ * Determina se o arquivo de teste é um Feature/Integration test pesado
32
+ * ou um Unit test rápido.
33
+ */
34
+ export function classifyTestType(filePath, fileContent) {
35
+ const normalized = filePath.toLowerCase();
36
+ if (normalized.endsWith('.test.ts') ||
37
+ normalized.includes('/e2e/') ||
38
+ normalized.includes('/integration/') ||
39
+ normalized.includes('/features/')) {
40
+ return 'feature';
41
+ }
42
+ for (const token of FEATURE_TEST_HEURISTICS) {
43
+ if (fileContent.includes(token))
44
+ return 'feature';
45
+ }
46
+ return 'unit';
47
+ }
48
+ export function isTestCall(call) {
49
+ const expr = call.getExpression();
50
+ const text = expr.getText();
51
+ return (text === 'test' ||
52
+ text === 'it' ||
53
+ text === 'test.only' ||
54
+ text === 'it.only' ||
55
+ text === 'test.skip' ||
56
+ text === 'it.skip' ||
57
+ text === 'test.concurrent' ||
58
+ text === 'it.concurrent' ||
59
+ text.startsWith('test.') ||
60
+ text.startsWith('it.'));
61
+ }
62
+ export function isSuiteCall(call) {
63
+ const expr = call.getExpression();
64
+ const text = expr.getText();
65
+ return (text === 'describe' ||
66
+ text === 'describe.only' ||
67
+ text === 'describe.skip' ||
68
+ text === 'describe.concurrent' ||
69
+ text === 'suite' ||
70
+ text === 'suite.only' ||
71
+ text === 'suite.skip' ||
72
+ text === 'context' ||
73
+ text === 'context.only' ||
74
+ text === 'context.skip' ||
75
+ text.startsWith('describe.') ||
76
+ text.startsWith('suite.') ||
77
+ text.startsWith('context.'));
78
+ }
79
+ export function suiteHasTests(call) {
80
+ return call.getDescendantsOfKind(SyntaxKind.CallExpression).some(isTestCall);
81
+ }
82
+ export async function removeFileAndEmptyParents(absPath, root) {
83
+ const { rm, readdir } = await import('node:fs/promises');
84
+ const { dirname } = await import('node:path');
85
+ await rm(absPath, { force: true });
86
+ let curDir = dirname(absPath);
87
+ const normalizedRoot = norm(root).replace(/\/+$/, '');
88
+ while (norm(curDir) !== normalizedRoot && norm(curDir).startsWith(normalizedRoot)) {
89
+ try {
90
+ const entries = await readdir(curDir);
91
+ if (entries.length === 0) {
92
+ await rm(curDir, { recursive: true, force: true });
93
+ curDir = dirname(curDir);
94
+ }
95
+ else {
96
+ break;
97
+ }
98
+ }
99
+ catch {
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ export function extractTestName(call) {
105
+ const args = call.getArguments();
106
+ if (args.length > 0 && Node.isStringLiteral(args[0])) {
107
+ return args[0].getLiteralText();
108
+ }
109
+ return '(anonymous test)';
110
+ }
111
+ export function analyzeTestBlock(call) {
112
+ const testName = extractTestName(call).toLowerCase();
113
+ const args = call.getArguments();
114
+ if (args.length < 2)
115
+ return { isRedundant: false };
116
+ const fnArg = args[1];
117
+ if (!fnArg)
118
+ return { isRedundant: false };
119
+ const allExpectCalls = fnArg.getDescendantsOfKind(SyntaxKind.CallExpression).filter((c) => {
120
+ const exprText = c.getExpression().getText();
121
+ return exprText === 'expect' || exprText.endsWith('.expect');
122
+ });
123
+ if (allExpectCalls.length === 0) {
124
+ return { isRedundant: false };
125
+ }
126
+ // 1. DI Boot Check: apenas verifica toBeDefined() / toBeTruthy() / toBeInstanceOf
127
+ const allAreBootChecks = allExpectCalls.every((c) => {
128
+ const parent = c.getParent();
129
+ if (parent && Node.isPropertyAccessExpression(parent)) {
130
+ const parentCall = parent.getParent();
131
+ if (parentCall && Node.isCallExpression(parentCall)) {
132
+ const matcher = parent.getName();
133
+ return matcher === 'toBeDefined' || matcher === 'toBeTruthy' || matcher === 'toBeInstanceOf';
134
+ }
135
+ }
136
+ return false;
137
+ });
138
+ if (allAreBootChecks) {
139
+ return {
140
+ isRedundant: true,
141
+ category: 'REDUNDANT_DI_BOOT_CHECK',
142
+ reason: 'Apenas checa se service/controller foi instanciado (toBeDefined), duplicando o compilador do Nest.',
143
+ };
144
+ }
145
+ const fnBodyText = fnArg.getText();
146
+ // 2. Tautological Mock: apenas configura jest.spyOn / jest.fn() e espera toHaveBeenCalled()
147
+ const hasSpies = fnBodyText.includes('spyOn(') || fnBodyText.includes('jest.fn(');
148
+ const onlyCallsMockAssertions = allExpectCalls.every((c) => {
149
+ const parent = c.getParent();
150
+ if (parent && Node.isPropertyAccessExpression(parent)) {
151
+ const matcher = parent.getName();
152
+ return (matcher === 'toHaveBeenCalled' ||
153
+ matcher === 'toHaveBeenCalledTimes' ||
154
+ matcher === 'toHaveBeenCalledWith');
155
+ }
156
+ return false;
157
+ });
158
+ if (hasSpies && onlyCallsMockAssertions && !fnBodyText.includes('expect(' + 'result') && !fnBodyText.includes('expect(' + 'res')) {
159
+ return {
160
+ isRedundant: true,
161
+ category: 'TAUTOLOGICAL_MOCK_CHECK',
162
+ reason: 'Teste tautológico: mocka um método e apenas testa se o mock foi chamado.',
163
+ };
164
+ }
165
+ // 3. Guard Check: apenas asserção de 401 / 403 / Forbidden / Unauthorized
166
+ const assertsUnauthorized = fnBodyText.includes('401') ||
167
+ fnBodyText.includes('403') ||
168
+ fnBodyText.includes('Unauthorized') ||
169
+ fnBodyText.includes('Forbidden');
170
+ const isGuardName = testName.includes('unauthenticated') ||
171
+ testName.includes('unauthorized') ||
172
+ testName.includes('forbidden') ||
173
+ testName.includes('without token') ||
174
+ testName.includes('no auth') ||
175
+ testName.includes('guard') ||
176
+ testName.includes('401') ||
177
+ testName.includes('403');
178
+ if (isGuardName && assertsUnauthorized) {
179
+ const hasDomainExpectations = allExpectCalls.some((c) => {
180
+ const text = c.getText();
181
+ return text.includes('.body') || text.includes('.json()') || text.includes('service.');
182
+ });
183
+ if (!hasDomainExpectations) {
184
+ return {
185
+ isRedundant: true,
186
+ category: 'REDUNDANT_GUARD_CHECK',
187
+ reason: 'Verificação redundante de HTTP 401/403: o guard já é garantido via invariante AST mutation-routes-guarded.',
188
+ };
189
+ }
190
+ }
191
+ // 4. DTO Validation Check: apenas asserção de 400 Bad Request
192
+ const assertsBadRequest = fnBodyText.includes('400') || fnBodyText.includes('Bad Request');
193
+ const isDtoName = testName.includes('missing') ||
194
+ testName.includes('invalid') ||
195
+ testName.includes('validation') ||
196
+ testName.includes('payload') ||
197
+ testName.includes('dto') ||
198
+ testName.includes('400');
199
+ if (isDtoName && assertsBadRequest) {
200
+ const hasDeepExpectations = allExpectCalls.length > 2;
201
+ if (!hasDeepExpectations) {
202
+ return {
203
+ isRedundant: true,
204
+ category: 'REDUNDANT_DTO_SCHEMA_CHECK',
205
+ reason: 'Verificação redundante de DTO schema: class-validator já é garantido estaticamente pela invariante dto-has-validation.',
206
+ };
207
+ }
208
+ }
209
+ return { isRedundant: false };
210
+ }
211
+ /**
212
+ * Realiza auditoria estática completa da suíte de testes.
213
+ */
214
+ export async function auditTestSuites(root, patterns = DEFAULT_TEST_PATTERNS, ignore, knownTestFiles) {
215
+ const picomatch = (await import('picomatch')).default;
216
+ const isMatch = picomatch(patterns, { dot: true });
217
+ const isIgnored = ignore && ignore.length > 0 ? picomatch(ignore, { dot: true }) : () => false;
218
+ let testFiles;
219
+ if (knownTestFiles) {
220
+ testFiles = knownTestFiles.filter((f) => isMatch(norm(f)) && !isIgnored(norm(f)));
221
+ }
222
+ else {
223
+ const allFiles = await walk(root, '.', {
224
+ extensions: ['.ts', '.tsx', '.js'],
225
+ ignorePatterns: ignore,
226
+ });
227
+ testFiles = allFiles
228
+ .filter((f) => !f.includes('node_modules') && !f.includes('/dist/'))
229
+ .filter((f) => isMatch(norm(f)) && !isIgnored(norm(f)));
230
+ }
231
+ const project = new Project({
232
+ useInMemoryFileSystem: false,
233
+ compilerOptions: {
234
+ target: 99,
235
+ experimentalDecorators: true,
236
+ emitDecoratorMetadata: true,
237
+ strict: false,
238
+ noResolve: true,
239
+ },
240
+ });
241
+ for (const file of testFiles) {
242
+ project.addSourceFileAtPath(resolve(root, file));
243
+ }
244
+ const sourceFiles = project.getSourceFiles();
245
+ let totalTestsFound = 0;
246
+ const candidates = [];
247
+ const byCategory = {
248
+ REDUNDANT_GUARD_CHECK: 0,
249
+ REDUNDANT_DTO_SCHEMA_CHECK: 0,
250
+ TAUTOLOGICAL_MOCK_CHECK: 0,
251
+ REDUNDANT_DI_BOOT_CHECK: 0,
252
+ };
253
+ const byType = {
254
+ feature: 0,
255
+ unit: 0,
256
+ };
257
+ for (const sf of sourceFiles) {
258
+ const relFile = norm(toRelPath(root, sf.getFilePath()));
259
+ const content = sf.getFullText();
260
+ const testType = classifyTestType(relFile, content);
261
+ const callExpressions = sf.getDescendantsOfKind(SyntaxKind.CallExpression);
262
+ for (const call of callExpressions) {
263
+ if (!isTestCall(call))
264
+ continue;
265
+ totalTestsFound++;
266
+ const analysis = analyzeTestBlock(call);
267
+ if (analysis.isRedundant && analysis.category) {
268
+ const line = call.getStartLineNumber();
269
+ const testName = extractTestName(call);
270
+ const estimatedSecondsSaved = testType === 'feature' ? FEATURE_TIME_SECONDS : UNIT_TIME_SECONDS;
271
+ byCategory[analysis.category]++;
272
+ byType[testType]++;
273
+ candidates.push({
274
+ id: `${relFile}:${line}`,
275
+ file: relFile,
276
+ line,
277
+ testName,
278
+ category: analysis.category,
279
+ testType,
280
+ estimatedSecondsSaved,
281
+ reason: analysis.reason ?? 'Redundant framework test duplicating Prumo AST invariants.',
282
+ snippet: call.getText().slice(0, 300),
283
+ });
284
+ }
285
+ }
286
+ }
287
+ const estimatedTotalSecondsSaved = Number(candidates.reduce((acc, c) => acc + c.estimatedSecondsSaved, 0).toFixed(2));
288
+ return {
289
+ scannedFiles: sourceFiles.length,
290
+ totalTestsFound,
291
+ redundantTestsFound: candidates.length,
292
+ estimatedTotalSecondsSaved,
293
+ byCategory,
294
+ byType,
295
+ candidates,
296
+ };
297
+ }
298
+ /**
299
+ * Poda com precisão cirúrgica de AST os testes redundantes selecionados (ou todos).
300
+ * Elimina automaticamente suítes vazias e arquivos de teste órfãos.
301
+ */
302
+ export async function pruneRedundantTests(root, testIds, patterns = DEFAULT_TEST_PATTERNS) {
303
+ const audit = await auditTestSuites(root, patterns);
304
+ const isAll = testIds === 'all' ||
305
+ (Array.isArray(testIds) && (testIds.length === 0 || testIds.includes('all')));
306
+ const targetIds = isAll
307
+ ? new Set(audit.candidates.map((c) => c.id))
308
+ : new Set(testIds);
309
+ const candidatesToPrune = audit.candidates.filter((c) => targetIds.has(c.id));
310
+ if (candidatesToPrune.length === 0) {
311
+ return {
312
+ prunedCount: 0,
313
+ estimatedSecondsSaved: 0,
314
+ prunedFiles: [],
315
+ removedFiles: [],
316
+ prunedCandidates: [],
317
+ };
318
+ }
319
+ const byFile = new Map();
320
+ for (const c of candidatesToPrune) {
321
+ const list = byFile.get(c.file) ?? [];
322
+ list.push(c);
323
+ byFile.set(c.file, list);
324
+ }
325
+ const project = new Project({
326
+ useInMemoryFileSystem: false,
327
+ compilerOptions: {
328
+ target: 99,
329
+ experimentalDecorators: true,
330
+ emitDecoratorMetadata: true,
331
+ strict: false,
332
+ noResolve: true,
333
+ },
334
+ });
335
+ const prunedFiles = [];
336
+ const removedFiles = [];
337
+ for (const [file, candidates] of byFile.entries()) {
338
+ const absPath = resolve(root, file);
339
+ const sf = project.addSourceFileAtPath(absPath);
340
+ const targetLines = new Set(candidates.map((c) => c.line));
341
+ const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression).filter(isTestCall);
342
+ const toRemove = calls.filter((c) => targetLines.has(c.getStartLineNumber()));
343
+ for (const call of toRemove) {
344
+ const stmt = call.getParentIfKind(SyntaxKind.ExpressionStatement);
345
+ if (stmt && !stmt.wasForgotten()) {
346
+ stmt.remove();
347
+ }
348
+ else if (!call.wasForgotten()) {
349
+ call.replaceWithText('');
350
+ }
351
+ }
352
+ // Remove suítes (describe/suite/context) que ficaram vazias (sem nenhum teste restante)
353
+ let removedSuite = true;
354
+ while (removedSuite) {
355
+ removedSuite = false;
356
+ const suiteCalls = sf
357
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
358
+ .filter(isSuiteCall);
359
+ for (const call of suiteCalls.reverse()) {
360
+ if (call.wasForgotten())
361
+ continue;
362
+ if (!suiteHasTests(call)) {
363
+ const stmt = call.getParentIfKind(SyntaxKind.ExpressionStatement);
364
+ if (stmt && !stmt.wasForgotten()) {
365
+ stmt.remove();
366
+ removedSuite = true;
367
+ }
368
+ else if (!call.wasForgotten()) {
369
+ call.replaceWithText('');
370
+ removedSuite = true;
371
+ }
372
+ }
373
+ }
374
+ }
375
+ const remainingTests = sf
376
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
377
+ .filter(isTestCall);
378
+ if (remainingTests.length === 0) {
379
+ // Todos os testes da suíte/arquivo foram purgados: remove o arquivo de teste
380
+ project.removeSourceFile(sf);
381
+ await removeFileAndEmptyParents(absPath, root);
382
+ removedFiles.push(file);
383
+ }
384
+ else {
385
+ await sf.save();
386
+ prunedFiles.push(file);
387
+ }
388
+ }
389
+ const estimatedSecondsSaved = Number(candidatesToPrune.reduce((acc, c) => acc + c.estimatedSecondsSaved, 0).toFixed(2));
390
+ return {
391
+ prunedCount: candidatesToPrune.length,
392
+ estimatedSecondsSaved,
393
+ prunedFiles,
394
+ removedFiles,
395
+ prunedCandidates: candidatesToPrune,
396
+ };
397
+ }
398
+ /**
399
+ * Definição canônica do plugin Jest para Prumo.
400
+ */
401
+ export function jestPlugin(options = {}) {
402
+ return definePlugin({
403
+ name: 'jest',
404
+ version: '0.6.0',
405
+ description: 'Jest AST inspection, mock tautology analysis, and surgical test pruning.',
406
+ rules: [],
407
+ });
408
+ }
409
+ export default jestPlugin;
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@ihelpee/prumo-plugin-node-jest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.8.3",
4
+ "type": "module",
5
+ "description": "Jest & test suite audit plugin for Prumo: AST redundancy detection, mock analysis, and surgical test pruning.",
6
+ "author": "Alex Gotardi <alexgotardi@gmail.com> (https://github.com/alexgotardi)",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "package.json",
19
+ "README.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/ihelpee/prumo.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.11"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@ihelpee/prumo": "0.8.3",
35
+ "picomatch": "^4.0.2",
36
+ "ts-morph": "^24.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/picomatch": "^3.0.1"
40
+ }
6
41
  }