@ihelpee/prumo-plugin-node-nest 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 +21 -0
- package/README.md +43 -2
- package/dist/ast.d.ts +45 -0
- package/dist/ast.d.ts.map +1 -0
- package/dist/ast.js +219 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/rules.d.ts +24 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +223 -0
- package/package.json +34 -3
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,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ihelpee/prumo-plugin-node-nest 📐
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Official NestJS architectural invariant plugin for [Prumo](https://www.npmjs.com/package/@ihelpee/prumo).
|
|
4
|
+
|
|
5
|
+
Provides static AST analysis rules and guardrails for NestJS codebases:
|
|
6
|
+
- **`mutation-routes-guarded`**: Enforces authentication / authorization guards on mutation routes (`@Post`, `@Put`, `@Patch`, `@Delete`).
|
|
7
|
+
- **`dto-has-validation`**: Verifies that DTO properties have class-validator decorators.
|
|
8
|
+
- **`providers-exported-when-cross-module`**: Validates dependency injection exports across module boundaries.
|
|
9
|
+
- **`no-cross-feature-import`**: Prevents architectural leakages between decoupled domain modules.
|
|
10
|
+
- **`monetary-is-integer`**: Prevents floating-point rounding errors for financial/monetary entities.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add -D @ihelpee/prumo-plugin-node-nest @ihelpee/prumo
|
|
16
|
+
# or
|
|
17
|
+
npm install --save-dev @ihelpee/prumo-plugin-node-nest @ihelpee/prumo
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
In your `prumo.config.ts`:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { defineConfig } from '@ihelpee/prumo';
|
|
26
|
+
import nestRules from '@ihelpee/prumo-plugin-node-nest';
|
|
27
|
+
|
|
28
|
+
export default defineConfig({
|
|
29
|
+
invariants: [
|
|
30
|
+
...nestRules,
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Author & Maintainer
|
|
36
|
+
|
|
37
|
+
**Alex Gotardi**
|
|
38
|
+
- 📧 Email: [alexgotardi@gmail.com](mailto:alexgotardi@gmail.com)
|
|
39
|
+
- 🐙 GitHub: [@alexgotardi](https://github.com/alexgotardi)
|
|
40
|
+
- 💼 LinkedIn: [linkedin.com/in/alexgotardi](https://linkedin.com/in/alexgotardi)
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT © Alex Gotardi and Prumo contributors.
|
package/dist/ast.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type SourceFile } from 'ts-morph';
|
|
2
|
+
export interface RouteInfo {
|
|
3
|
+
/** `POST /orders/:id/refund` */
|
|
4
|
+
id: string;
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
controller: string;
|
|
8
|
+
handler: string;
|
|
9
|
+
file: string;
|
|
10
|
+
line: number;
|
|
11
|
+
/** Guards declarados no handler. */
|
|
12
|
+
handlerGuards: string[];
|
|
13
|
+
/** Guards declarados na classe. */
|
|
14
|
+
classGuards: string[];
|
|
15
|
+
/** Guards globais registrados via APP_GUARD. */
|
|
16
|
+
globalGuards: string[];
|
|
17
|
+
isPublic: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function extractRoutes(root: string, files: SourceFile[]): RouteInfo[];
|
|
20
|
+
export declare function extractGlobalGuards(files: SourceFile[]): string[];
|
|
21
|
+
export interface DtoProperty {
|
|
22
|
+
name: string;
|
|
23
|
+
type: string;
|
|
24
|
+
hasValidationDecorator: boolean;
|
|
25
|
+
decorators: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface DtoInfo {
|
|
28
|
+
name: string;
|
|
29
|
+
file: string;
|
|
30
|
+
line: number;
|
|
31
|
+
properties: DtoProperty[];
|
|
32
|
+
usedInRoute: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare function extractDtos(root: string, files: SourceFile[]): DtoInfo[];
|
|
35
|
+
export interface ModuleInfo {
|
|
36
|
+
file: string;
|
|
37
|
+
dir: string;
|
|
38
|
+
providers: string[];
|
|
39
|
+
exports: string[];
|
|
40
|
+
controllers: string[];
|
|
41
|
+
imports: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare function extractModules(root: string, files: SourceFile[]): ModuleInfo[];
|
|
44
|
+
export declare function extractInjectables(files: SourceFile[]): Set<string>;
|
|
45
|
+
//# sourceMappingURL=ast.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ast.d.ts","sourceRoot":"","sources":["../src/ast.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAcvD,MAAM,WAAW,SAAS;IACxB,gCAAgC;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,oCAAoC;IACpC,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,mCAAmC;IACnC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,gDAAgD;IAChD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,SAAS,EAAE,CA6C5E;AAgBD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,MAAM,EAAE,CAgBjE;AAeD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,sBAAsB,EAAE,OAAO,CAAC;IAChC,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,WAAW,EAAE,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;CACtB;AAcD,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,OAAO,EAAE,CAqCxE;AAyBD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,GAAG,UAAU,EAAE,CAqB9E;AAYD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAUnE"}
|
package/dist/ast.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { relPath, decoratorsOf, hasDecorator } from '@ihelpee/prumo/invariants';
|
|
3
|
+
const HTTP_METHODS = {
|
|
4
|
+
Get: 'GET',
|
|
5
|
+
Post: 'POST',
|
|
6
|
+
Put: 'PUT',
|
|
7
|
+
Patch: 'PATCH',
|
|
8
|
+
Delete: 'DELETE',
|
|
9
|
+
Head: 'HEAD',
|
|
10
|
+
Options: 'OPTIONS',
|
|
11
|
+
All: 'ALL',
|
|
12
|
+
};
|
|
13
|
+
export function extractRoutes(root, files) {
|
|
14
|
+
const globalGuards = extractGlobalGuards(files);
|
|
15
|
+
const routes = [];
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
for (const cls of file.getClasses()) {
|
|
18
|
+
if (!hasDecorator(cls, 'Controller'))
|
|
19
|
+
continue;
|
|
20
|
+
const controllerDecorator = decoratorsOf(cls).find((d) => d.name === 'Controller');
|
|
21
|
+
const basePath = stripQuotes(controllerDecorator?.args[0] ?? '');
|
|
22
|
+
const classGuards = collectGuards(decoratorsOf(cls));
|
|
23
|
+
const classPublic = decoratorsOf(cls).some((d) => d.name === 'Public');
|
|
24
|
+
for (const method of cls.getMethods()) {
|
|
25
|
+
const decorators = decoratorsOf(method);
|
|
26
|
+
const http = decorators.find((d) => d.name in HTTP_METHODS);
|
|
27
|
+
if (!http)
|
|
28
|
+
continue;
|
|
29
|
+
const verb = HTTP_METHODS[http.name];
|
|
30
|
+
const subPath = stripQuotes(http.args[0] ?? '');
|
|
31
|
+
const path = joinPath(basePath, subPath);
|
|
32
|
+
const handlerGuards = collectGuards(decorators);
|
|
33
|
+
routes.push({
|
|
34
|
+
id: `${verb} ${path || '/'}`,
|
|
35
|
+
method: verb,
|
|
36
|
+
path: path || '/',
|
|
37
|
+
controller: cls.getName() ?? '(anon)',
|
|
38
|
+
handler: method.getName(),
|
|
39
|
+
file: relPath(root, file),
|
|
40
|
+
line: method.getStartLineNumber(),
|
|
41
|
+
handlerGuards,
|
|
42
|
+
classGuards,
|
|
43
|
+
globalGuards,
|
|
44
|
+
isPublic: decorators.some((d) => d.name === 'Public') ||
|
|
45
|
+
classPublic ||
|
|
46
|
+
handlerGuards.length === 0 && classGuards.length === 0 && globalGuards.length === 0
|
|
47
|
+
? decorators.some((d) => d.name === 'Public') || classPublic
|
|
48
|
+
: false,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return routes;
|
|
54
|
+
}
|
|
55
|
+
function collectGuards(decorators) {
|
|
56
|
+
const guards = [];
|
|
57
|
+
for (const d of decorators) {
|
|
58
|
+
if (d.name !== 'UseGuards')
|
|
59
|
+
continue;
|
|
60
|
+
for (const arg of d.args) {
|
|
61
|
+
for (const g of arg.split(',')) {
|
|
62
|
+
const name = g.trim().replace(/\(.*$/, '').trim();
|
|
63
|
+
if (name)
|
|
64
|
+
guards.push(name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return guards;
|
|
69
|
+
}
|
|
70
|
+
export function extractGlobalGuards(files) {
|
|
71
|
+
const guards = [];
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const text = file.getFullText();
|
|
74
|
+
if (!text.includes('APP_GUARD'))
|
|
75
|
+
continue;
|
|
76
|
+
const re = /provide:\s*APP_GUARD\s*,?\s*useClass:\s*([A-Za-z0-9_]+)/g;
|
|
77
|
+
let m;
|
|
78
|
+
while ((m = re.exec(text)) !== null) {
|
|
79
|
+
if (m[1])
|
|
80
|
+
guards.push(m[1]);
|
|
81
|
+
}
|
|
82
|
+
const re2 = /useClass:\s*([A-Za-z0-9_]+)\s*,?\s*provide:\s*APP_GUARD/g;
|
|
83
|
+
while ((m = re2.exec(text)) !== null) {
|
|
84
|
+
if (m[1])
|
|
85
|
+
guards.push(m[1]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...new Set(guards)];
|
|
89
|
+
}
|
|
90
|
+
function stripQuotes(s) {
|
|
91
|
+
return s.replace(/^['"`]/, '').replace(/['"`]$/, '');
|
|
92
|
+
}
|
|
93
|
+
function joinPath(base, sub) {
|
|
94
|
+
const b = base.replace(/^\/|\/$/g, '');
|
|
95
|
+
const s = sub.replace(/^\/|\/$/g, '');
|
|
96
|
+
if (!b && !s)
|
|
97
|
+
return '';
|
|
98
|
+
if (!b)
|
|
99
|
+
return s;
|
|
100
|
+
if (!s)
|
|
101
|
+
return b;
|
|
102
|
+
return `${b}/${s}`;
|
|
103
|
+
}
|
|
104
|
+
const VALIDATION_DECORATORS = new Set([
|
|
105
|
+
'IsString', 'IsNumber', 'IsInt', 'IsFloat', 'IsBoolean', 'IsOptional',
|
|
106
|
+
'IsEmail', 'IsUrl', 'IsUuid', 'IsEnum', 'IsArray', 'IsObject', 'IsDate',
|
|
107
|
+
'IsPositive', 'IsMinLength', 'IsMaxLength', 'Min', 'Max', 'Length',
|
|
108
|
+
'IsNotEmpty', 'IsIn', 'IsMongoId', 'IsPhoneNumber', 'IsBase64',
|
|
109
|
+
'IsString', 'IsBooleanString', 'IsNumberString', 'IsPositiveInt',
|
|
110
|
+
'IsNegative', 'IsGreaterThan', 'IsLessThan', 'ArrayNotEmpty',
|
|
111
|
+
'ArrayMinSize', 'ArrayMaxSize', 'ValidateBy', 'Validate', 'ValidateIf',
|
|
112
|
+
'IsDefined', 'IsNotEmptyObject', 'IsInstance', 'MinDate', 'MaxDate',
|
|
113
|
+
'IsAlpha', 'IsAlphanumeric', 'IsCreditCard', 'IsCurrency', 'IsJson',
|
|
114
|
+
]);
|
|
115
|
+
export function extractDtos(root, files) {
|
|
116
|
+
const bodyTypes = collectBodyTypes(files);
|
|
117
|
+
const dtos = [];
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
for (const cls of file.getClasses()) {
|
|
120
|
+
const name = cls.getName();
|
|
121
|
+
if (!name)
|
|
122
|
+
continue;
|
|
123
|
+
if (!bodyTypes.has(name))
|
|
124
|
+
continue;
|
|
125
|
+
const properties = cls
|
|
126
|
+
.getProperties()
|
|
127
|
+
.filter((p) => !p.hasModifier(SyntaxKind.PrivateKeyword) &&
|
|
128
|
+
!p.hasModifier(SyntaxKind.ProtectedKeyword))
|
|
129
|
+
.map((p) => {
|
|
130
|
+
const decorators = p.getDecorators().map((d) => d.getName());
|
|
131
|
+
return {
|
|
132
|
+
name: p.getName(),
|
|
133
|
+
type: p.getType().getText(),
|
|
134
|
+
hasValidationDecorator: decorators.some((d) => VALIDATION_DECORATORS.has(d)),
|
|
135
|
+
decorators,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
dtos.push({
|
|
139
|
+
name,
|
|
140
|
+
file: relPath(root, file),
|
|
141
|
+
line: cls.getStartLineNumber(),
|
|
142
|
+
properties,
|
|
143
|
+
usedInRoute: true,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return dtos;
|
|
148
|
+
}
|
|
149
|
+
function collectBodyTypes(files) {
|
|
150
|
+
const types = new Set();
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
for (const cls of file.getClasses()) {
|
|
153
|
+
if (!hasDecorator(cls, 'Controller'))
|
|
154
|
+
continue;
|
|
155
|
+
for (const method of cls.getMethods()) {
|
|
156
|
+
for (const param of method.getParameters()) {
|
|
157
|
+
const decorators = param.getDecorators().map((d) => d.getName());
|
|
158
|
+
if (!decorators.includes('Body'))
|
|
159
|
+
continue;
|
|
160
|
+
const typeNode = param.getTypeNode()?.getText();
|
|
161
|
+
if (!typeNode)
|
|
162
|
+
continue;
|
|
163
|
+
for (const t of typeNode.split(/[|&]/)) {
|
|
164
|
+
const n = t.trim().replace(/<.*>$/, '').replace(/\[\]$/, '');
|
|
165
|
+
if (n && /^[A-Z]/.test(n))
|
|
166
|
+
types.add(n);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return types;
|
|
173
|
+
}
|
|
174
|
+
export function extractModules(root, files) {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
for (const cls of file.getClasses()) {
|
|
178
|
+
const deco = cls.getDecorators().find((d) => d.getName() === 'Module');
|
|
179
|
+
if (!deco)
|
|
180
|
+
continue;
|
|
181
|
+
const arg = deco.getArguments()[0]?.getText();
|
|
182
|
+
if (!arg)
|
|
183
|
+
continue;
|
|
184
|
+
const file_ = relPath(root, file);
|
|
185
|
+
out.push({
|
|
186
|
+
file: file_,
|
|
187
|
+
dir: file_.split('/').slice(0, -1).join('/'),
|
|
188
|
+
providers: arrayProp(arg, 'providers'),
|
|
189
|
+
exports: arrayProp(arg, 'exports'),
|
|
190
|
+
controllers: arrayProp(arg, 'controllers'),
|
|
191
|
+
imports: arrayProp(arg, 'imports'),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
function arrayProp(objText, key) {
|
|
198
|
+
const re = new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'm');
|
|
199
|
+
const m = objText.match(re);
|
|
200
|
+
if (!m?.[1])
|
|
201
|
+
return [];
|
|
202
|
+
return m[1]
|
|
203
|
+
.split(',')
|
|
204
|
+
.map((s) => s.trim().replace(/\/\/.*$/, '').trim())
|
|
205
|
+
.filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s));
|
|
206
|
+
}
|
|
207
|
+
export function extractInjectables(files) {
|
|
208
|
+
const out = new Set();
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
for (const cls of file.getClasses()) {
|
|
211
|
+
const n = cls.getName();
|
|
212
|
+
if (!n)
|
|
213
|
+
continue;
|
|
214
|
+
if (cls.getDecorators().some((d) => d.getName() === 'Injectable'))
|
|
215
|
+
out.add(n);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,UAAU,IAAI,OAAO,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RuleDef } from '@ihelpee/prumo/invariants';
|
|
2
|
+
/** Rota de mutação sem guard em nenhuma das três camadas. */
|
|
3
|
+
export declare const mutationRoutesGuarded: RuleDef;
|
|
4
|
+
/** DTO usado em @Body() sem decorator de validação do class-validator. */
|
|
5
|
+
export declare const dtoHasValidation: RuleDef;
|
|
6
|
+
/** Imports entre FEATURES (fronteiras de contexto). */
|
|
7
|
+
export declare const noCrossFeatureImport: RuleDef;
|
|
8
|
+
/** Provider usado fora do módulo que o declara, sem exports. */
|
|
9
|
+
export declare const providersExportedWhenCrossModule: RuleDef;
|
|
10
|
+
/** Valor monetário como float. */
|
|
11
|
+
export declare const monetaryIsInteger: RuleDef;
|
|
12
|
+
/** Guard referenciado que não existe como classe declarada. */
|
|
13
|
+
export declare const guardsResolve: RuleDef;
|
|
14
|
+
/** Família driftada: arquivos quase idênticos. */
|
|
15
|
+
export declare const familyDrift: RuleDef;
|
|
16
|
+
/**
|
|
17
|
+
* O Plugin oficial do NestJS para o Prumo.
|
|
18
|
+
*
|
|
19
|
+
* Implementa o contrato PrumoPlugin (estilo Cordis / DeepSeek harness),
|
|
20
|
+
* registrando extratores de AST NestJS sob demanda e seu preset de invariantes.
|
|
21
|
+
*/
|
|
22
|
+
export declare const nestPlugin: import("@ihelpee/prumo/invariants").PrumoPlugin;
|
|
23
|
+
export declare const nestPreset: RuleDef[];
|
|
24
|
+
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../src/rules.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAA0C,MAAM,2BAA2B,CAAC;AAKrH,6DAA6D;AAC7D,eAAO,MAAM,qBAAqB,SAmBxB,CAAC;AAEX,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,SAgBnB,CAAC;AAcX,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,SAavB,CAAC;AAWX,gEAAgE;AAChE,eAAO,MAAM,gCAAgC,SAuCnC,CAAC;AAEX,kCAAkC;AAClC,eAAO,MAAM,iBAAiB,SASpB,CAAC;AAEX,+DAA+D;AAC/D,eAAO,MAAM,aAAa,SAgBhB,CAAC;AAEX,kDAAkD;AAClD,eAAO,MAAM,WAAW,SA4Cd,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,UAAU,iDAmBrB,CAAC;AAEH,eAAO,MAAM,UAAU,EAAE,OAAO,EAAqB,CAAC"}
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { rule, definePlugin } from '@ihelpee/prumo/invariants';
|
|
2
|
+
import { extractRoutes, extractDtos, extractModules, extractInjectables } from './ast.js';
|
|
3
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
4
|
+
/** Rota de mutação sem guard em nenhuma das três camadas. */
|
|
5
|
+
export const mutationRoutesGuarded = rule('mutation-routes-guarded')
|
|
6
|
+
.needsData('routes')
|
|
7
|
+
.message('rota de mutação sem guard (handler, classe e APP_GUARD ausentes)')
|
|
8
|
+
.assert((ctx) => {
|
|
9
|
+
const routes = (ctx.routes ?? ctx.data?.['routes'] ?? []);
|
|
10
|
+
return routes
|
|
11
|
+
.filter((r) => MUTATING.has(r.method))
|
|
12
|
+
.filter((r) => !r.isPublic)
|
|
13
|
+
.filter((r) => r.handlerGuards.length === 0 &&
|
|
14
|
+
r.classGuards.length === 0 &&
|
|
15
|
+
r.globalGuards.length === 0)
|
|
16
|
+
.map((r) => ({
|
|
17
|
+
target: `${r.id} (${r.controller}.${r.handler})`,
|
|
18
|
+
location: `${r.file}:${r.line}`,
|
|
19
|
+
}));
|
|
20
|
+
})
|
|
21
|
+
.build();
|
|
22
|
+
/** DTO usado em @Body() sem decorator de validação do class-validator. */
|
|
23
|
+
export const dtoHasValidation = rule('dto-has-validation')
|
|
24
|
+
.needsData('dtos')
|
|
25
|
+
.message('DTO usado em @Body() sem class-validator: ValidationPipe whitelist stripa tudo')
|
|
26
|
+
.assert((ctx) => {
|
|
27
|
+
const dtos = (ctx.dtos ?? ctx.data?.['dtos'] ?? []);
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const dto of dtos) {
|
|
30
|
+
const unvalidated = dto.properties.filter((p) => !p.hasValidationDecorator);
|
|
31
|
+
if (unvalidated.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
out.push({
|
|
34
|
+
target: `${dto.name}.${unvalidated.map((p) => p.name).join(', ')}`,
|
|
35
|
+
location: `${dto.file}:${dto.line}`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
})
|
|
40
|
+
.build();
|
|
41
|
+
const SHARED = new Set(['common', 'shared', 'core', 'config', 'database']);
|
|
42
|
+
function featureOf(p) {
|
|
43
|
+
const parts = p.split('/');
|
|
44
|
+
const i = parts.indexOf('modules');
|
|
45
|
+
const base = i >= 0 ? i + 1 : parts[0] === 'src' ? 1 : -1;
|
|
46
|
+
if (base < 0 || !parts[base])
|
|
47
|
+
return null;
|
|
48
|
+
const name = parts[base];
|
|
49
|
+
if (SHARED.has(name))
|
|
50
|
+
return null;
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
/** Imports entre FEATURES (fronteiras de contexto). */
|
|
54
|
+
export const noCrossFeatureImport = rule('no-cross-feature-import')
|
|
55
|
+
.needsData('imports')
|
|
56
|
+
.message('importação entre features (fronteira de contexto atravessada)')
|
|
57
|
+
.assert((ctx) => {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const edge of ctx.imports) {
|
|
60
|
+
const a = featureOf(edge.from);
|
|
61
|
+
const b = featureOf(edge.resolved);
|
|
62
|
+
if (!a || !b || a === b)
|
|
63
|
+
continue;
|
|
64
|
+
out.push({ target: `${edge.from} -> ${edge.resolved}` });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
})
|
|
68
|
+
.build();
|
|
69
|
+
function fileDeclaring(ctx, sym) {
|
|
70
|
+
return ctx.declared.get(sym) ?? null;
|
|
71
|
+
}
|
|
72
|
+
function moduleOf(p) {
|
|
73
|
+
const dir = p.split('/').slice(0, -1).join('/');
|
|
74
|
+
return dir || null;
|
|
75
|
+
}
|
|
76
|
+
/** Provider usado fora do módulo que o declara, sem exports. */
|
|
77
|
+
export const providersExportedWhenCrossModule = rule('providers-exported-when-cross-module')
|
|
78
|
+
.needsData('imports', 'modules', 'injectables')
|
|
79
|
+
.message('provider usado por módulo irmão sem estar em `exports` do módulo que o declara')
|
|
80
|
+
.assert((ctx) => {
|
|
81
|
+
const modules = (ctx.modules ?? ctx.data?.['modules'] ?? []);
|
|
82
|
+
const injectables = (ctx.injectables ?? ctx.data?.['injectables'] ?? new Set());
|
|
83
|
+
const out = [];
|
|
84
|
+
const declaringModule = new Map();
|
|
85
|
+
for (const mod of modules) {
|
|
86
|
+
for (const prov of mod.providers) {
|
|
87
|
+
const file = fileDeclaring(ctx, prov);
|
|
88
|
+
if (!file)
|
|
89
|
+
continue;
|
|
90
|
+
declaringModule.set(file, { file: mod.file, exports: mod.exports });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const edge of ctx.imports) {
|
|
94
|
+
const fromMod = moduleOf(edge.from);
|
|
95
|
+
const toMod = moduleOf(edge.resolved);
|
|
96
|
+
if (!fromMod || !toMod || fromMod === toMod)
|
|
97
|
+
continue;
|
|
98
|
+
for (const sym of edge.symbols) {
|
|
99
|
+
if (!injectables.has(sym))
|
|
100
|
+
continue;
|
|
101
|
+
const declFile = fileDeclaring(ctx, sym);
|
|
102
|
+
if (!declFile)
|
|
103
|
+
continue;
|
|
104
|
+
const owner = declaringModule.get(declFile);
|
|
105
|
+
if (!owner)
|
|
106
|
+
continue;
|
|
107
|
+
if (edge.from === owner.file)
|
|
108
|
+
continue;
|
|
109
|
+
if (owner.exports.includes(sym))
|
|
110
|
+
continue;
|
|
111
|
+
out.push({
|
|
112
|
+
target: `${sym} usado em ${edge.from} mas não exportado por ${owner.file}`,
|
|
113
|
+
location: edge.from,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
})
|
|
119
|
+
.build();
|
|
120
|
+
/** Valor monetário como float. */
|
|
121
|
+
export const monetaryIsInteger = rule('monetary-is-integer')
|
|
122
|
+
.severity('warn')
|
|
123
|
+
.needsData('contractFields')
|
|
124
|
+
.message('campo monetário tipado como number/float — use inteiro em centavos')
|
|
125
|
+
.assert((ctx) => ctx.contractFields
|
|
126
|
+
.filter((f) => /^(number|float|double|decimal)$/.test(f.type.toLowerCase()))
|
|
127
|
+
.map((f) => ({ target: `${f.parent}.${f.name}: ${f.type}` })))
|
|
128
|
+
.build();
|
|
129
|
+
/** Guard referenciado que não existe como classe declarada. */
|
|
130
|
+
export const guardsResolve = rule('guards-resolve')
|
|
131
|
+
.needsData('routes')
|
|
132
|
+
.message('@UseGuards referencia guard que não existe como classe declarada no projeto')
|
|
133
|
+
.assert((ctx) => {
|
|
134
|
+
const routes = (ctx.routes ?? ctx.data?.['routes'] ?? []);
|
|
135
|
+
const declared = ctx.declared;
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const r of routes) {
|
|
138
|
+
for (const g of [...r.handlerGuards, ...r.classGuards]) {
|
|
139
|
+
if (g.startsWith('APP_'))
|
|
140
|
+
continue;
|
|
141
|
+
if (declared.has(g))
|
|
142
|
+
continue;
|
|
143
|
+
out.push({ target: `${r.id} -> @UseGuards(${g})`, location: `${r.file}:${r.line}` });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
})
|
|
148
|
+
.build();
|
|
149
|
+
/** Família driftada: arquivos quase idênticos. */
|
|
150
|
+
export const familyDrift = rule('family-drift')
|
|
151
|
+
.severity('warn')
|
|
152
|
+
.needsData('imports')
|
|
153
|
+
.message('arquivos quase identicos: familia driftada, o regime onde o agente deriva em vez de especificar')
|
|
154
|
+
.assert(async (ctx) => {
|
|
155
|
+
const { similarity } = await import('@ihelpee/prumo/core');
|
|
156
|
+
const { readFile } = await import('node:fs/promises');
|
|
157
|
+
const { resolve } = await import('node:path');
|
|
158
|
+
const byFamily = new Map();
|
|
159
|
+
for (const f of ctx.files) {
|
|
160
|
+
const m = f.match(/^src\/([^/]+)\//);
|
|
161
|
+
const fam = m?.[1];
|
|
162
|
+
if (!fam)
|
|
163
|
+
continue;
|
|
164
|
+
const list = byFamily.get(fam) ?? [];
|
|
165
|
+
list.push(f);
|
|
166
|
+
byFamily.set(fam, list);
|
|
167
|
+
}
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const [fam, files] of byFamily) {
|
|
170
|
+
if (files.length < 2)
|
|
171
|
+
continue;
|
|
172
|
+
const cache = new Map();
|
|
173
|
+
const read = async (f) => {
|
|
174
|
+
if (!cache.has(f))
|
|
175
|
+
cache.set(f, await readFile(resolve(ctx.root, f), 'utf8'));
|
|
176
|
+
return cache.get(f);
|
|
177
|
+
};
|
|
178
|
+
for (let i = 0; i < files.length; i++) {
|
|
179
|
+
for (let j = i + 1; j < files.length; j++) {
|
|
180
|
+
const a = files[i];
|
|
181
|
+
const b = files[j];
|
|
182
|
+
if (!a || !b)
|
|
183
|
+
continue;
|
|
184
|
+
const sim = similarity(await read(a), await read(b), 1);
|
|
185
|
+
if (sim >= 0.6) {
|
|
186
|
+
out.push({
|
|
187
|
+
target: `${fam}: ${a} ~ ${b} (${(sim * 100).toFixed(0)}% identicos)`,
|
|
188
|
+
location: a,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
})
|
|
196
|
+
.build();
|
|
197
|
+
/**
|
|
198
|
+
* O Plugin oficial do NestJS para o Prumo.
|
|
199
|
+
*
|
|
200
|
+
* Implementa o contrato PrumoPlugin (estilo Cordis / DeepSeek harness),
|
|
201
|
+
* registrando extratores de AST NestJS sob demanda e seu preset de invariantes.
|
|
202
|
+
*/
|
|
203
|
+
export const nestPlugin = definePlugin({
|
|
204
|
+
name: 'nest',
|
|
205
|
+
version: '0.1.3',
|
|
206
|
+
description: 'NestJS framework invariants: route guards, DTO validation, and module DI exports.',
|
|
207
|
+
extractors: {
|
|
208
|
+
routes: (ctx) => extractRoutes(ctx.root, (ctx.sourceFiles ?? [])),
|
|
209
|
+
dtos: (ctx) => extractDtos(ctx.root, (ctx.sourceFiles ?? [])),
|
|
210
|
+
modules: (ctx) => extractModules(ctx.root, (ctx.sourceFiles ?? [])),
|
|
211
|
+
injectables: (ctx) => extractInjectables((ctx.sourceFiles ?? [])),
|
|
212
|
+
},
|
|
213
|
+
rules: [
|
|
214
|
+
mutationRoutesGuarded,
|
|
215
|
+
dtoHasValidation,
|
|
216
|
+
noCrossFeatureImport,
|
|
217
|
+
providersExportedWhenCrossModule,
|
|
218
|
+
monetaryIsInteger,
|
|
219
|
+
guardsResolve,
|
|
220
|
+
familyDrift,
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
export const nestPreset = nestPlugin.rules;
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ihelpee/prumo-plugin-node-nest",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.8.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "NestJS plugin for Prumo: mutation route guards, DTO validation, and module DI invariants.",
|
|
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
|
+
"ts-morph": "^24.0.0"
|
|
36
|
+
}
|
|
6
37
|
}
|