@produtype/core 0.26.0 → 0.27.0
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.
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectAuth = detectAuth;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const roleChecks_1 = require("./structural/roleChecks");
|
|
6
7
|
const absenceEvidence_1 = require("./absenceEvidence");
|
|
7
8
|
/**
|
|
8
9
|
* In a product that talks to a model, `role` usually means who is speaking.
|
|
@@ -90,7 +91,20 @@ async function detectAuth(ctx) {
|
|
|
90
91
|
/createServerClient/,
|
|
91
92
|
/\[\.\.\.nextauth\]/i,
|
|
92
93
|
], 30);
|
|
93
|
-
|
|
94
|
+
/**
|
|
95
|
+
* `otp` as a word, not as three letters inside another one.
|
|
96
|
+
*
|
|
97
|
+
* `/otp/i` matched `VarError::NotPresent` and `PandasUseOfDotPivotOrUpdate` — N-**otp**-resent
|
|
98
|
+
* and D-**otp**-ivot — so a Rust linter was credited with two-factor authentication.
|
|
99
|
+
* Two of the three signals behind that reading were substring collisions of this kind.
|
|
100
|
+
*
|
|
101
|
+
* Word boundaries in the languages people actually write: delimited by a non-letter
|
|
102
|
+
* (`otp_secret`, `verify(otp)`), or the capital that starts a camelCase word
|
|
103
|
+
* (`verifyOtp`, `otpCode`). `pyotp` no longer matches here and does not need to: it is
|
|
104
|
+
* a dependency, and dependencies are read from the manifest above.
|
|
105
|
+
*/
|
|
106
|
+
const OTP_AS_A_WORD = /(?:^|[^a-z])t?otp(?:[^a-z]|$)|[a-z_](?:Otp|OTP|Totp|TOTP)/;
|
|
107
|
+
const twoFaSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/two[_-]?factor/i, OTP_AS_A_WORD], 20);
|
|
94
108
|
const apiKeySignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
95
109
|
/**
|
|
96
110
|
* A key this project checks, not a key it holds.
|
|
@@ -143,15 +157,34 @@ async function detectAuth(ctx) {
|
|
|
143
157
|
], 20);
|
|
144
158
|
const emailVerificationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/verify\s*email/i, /email[_-]?verification/i, /confirm\s*email/i, /isEmailVerified/i], 20);
|
|
145
159
|
const sessionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/session/i, /cookie/i, /jwt/i, /refresh\s*token/i, /httpOnly/i], 25);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Structure first, text underneath.
|
|
162
|
+
*
|
|
163
|
+
* `excludeChatTurnRoles` below is three patches written in one day, each against a
|
|
164
|
+
* shape the last one missed: a chat transcript, a game's `part.role`, JSX picking an
|
|
165
|
+
* icon. A syntax tree asks the question all three were really about — does this
|
|
166
|
+
* comparison guard something, or label something — and answers it for shapes nobody
|
|
167
|
+
* has thought of yet.
|
|
168
|
+
*
|
|
169
|
+
* `null` means the question could not be asked, because the optional TypeScript
|
|
170
|
+
* dependency is not installed. It is not "no roles found": the two are different
|
|
171
|
+
* answers and confusing them is the mistake this product exists to avoid. When it is
|
|
172
|
+
* null, the text search below runs exactly as before.
|
|
173
|
+
*/
|
|
174
|
+
const structuralRoles = await (0, roleChecks_1.readRoleChecks)(ctx.root, sourceFiles);
|
|
175
|
+
const guardedRoles = structuralRoles?.filter((check) => check.kind === 'guard') ?? null;
|
|
176
|
+
/**
|
|
177
|
+
* Two kinds of signal, and only one of them was ever ambiguous.
|
|
178
|
+
*
|
|
179
|
+
* `requireRole(...)`, `isAdmin`, `roles.includes(...)` say what they are in the text:
|
|
180
|
+
* nobody writes `requireRole` to render a label. The comparison — `x.role === 'y'` —
|
|
181
|
+
* is the one that meant three different things in three repositories, and it is the
|
|
182
|
+
* one the tree answers.
|
|
183
|
+
*/
|
|
184
|
+
const unambiguousRoles = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/requireRole/i, /isAdmin/i, /SUPER_ADMIN/i, /roles\.includes\(/i], 20);
|
|
185
|
+
const comparedRoles = guardedRoles
|
|
186
|
+
?? excludeChatTurnRoles(await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/req\.user\.role/i, /user\.role/i, /role\s*===/i], 20));
|
|
187
|
+
const roleSignals = [...unambiguousRoles, ...comparedRoles].slice(0, 20);
|
|
155
188
|
const permissionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/requirePermission/i, /permission_classes/i, /permissions\.py/i, /authorize\(/i, /\bcan\(/i], 20);
|
|
156
189
|
const resourceLevelSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
157
190
|
/requirePermission/i,
|
|
@@ -206,8 +239,17 @@ async function detectAuth(ctx) {
|
|
|
206
239
|
const organizationSignals = strongOrganization.length > 0
|
|
207
240
|
? [...strongOrganization, ...weakOrganization]
|
|
208
241
|
: [];
|
|
209
|
-
|
|
210
|
-
|
|
242
|
+
/**
|
|
243
|
+
* `memberId` is a weak word, by this detector's own rule.
|
|
244
|
+
*
|
|
245
|
+
* It was strong enough to stand alone, and `ScopedMemberId` — a symbol table in a
|
|
246
|
+
* compiler — was read as a tenant membership. "Member" means a struct field in most
|
|
247
|
+
* languages and a person in a few; only a tenant word says which. The comment above
|
|
248
|
+
* already states the principle: a weak word never stands on its own, however many
|
|
249
|
+
* files it appears in.
|
|
250
|
+
*/
|
|
251
|
+
const strongMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, STRONG_TENANCY, 25);
|
|
252
|
+
const weakMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/memberId/i, ...WEAK_TENANCY], 25);
|
|
211
253
|
const membershipSignals = strongMembership.length > 0 ? [...strongMembership, ...weakMembership] : [];
|
|
212
254
|
const b2bSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
213
255
|
/stripe/i,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadTypeScript = loadTypeScript;
|
|
4
|
+
exports.resetTypeScriptCache = resetTypeScriptCache;
|
|
5
|
+
/**
|
|
6
|
+
* The TypeScript compiler, loaded when something needs to read structure rather than text.
|
|
7
|
+
*
|
|
8
|
+
* This analyzer reads source as prose: it searches for words and reports what it finds.
|
|
9
|
+
* That is why `/otp/i` matched `VarError::NotPresent`, why `memberId` matched
|
|
10
|
+
* `ScopedMemberId` in a compiler's symbol table, and why a chat transcript
|
|
11
|
+
* (`{m.role === "user" ? "Utente" : "Assistente"}`) was read as an authorization model.
|
|
12
|
+
* Each was patched with a narrower pattern, and a narrower pattern is still a pattern.
|
|
13
|
+
*
|
|
14
|
+
* A syntax tree answers questions a regular expression cannot ask: whether a comparison
|
|
15
|
+
* guards an action or picks a label, whether an identifier is a property or a string in
|
|
16
|
+
* a table, whether a call is made or merely named.
|
|
17
|
+
*
|
|
18
|
+
* Optional, and loaded the way this package already loads the MCP SDK and the AI layer:
|
|
19
|
+
* a variable specifier so the build does not require it, and a null return rather than a
|
|
20
|
+
* throw when it is absent. Text search remains the floor. Structure is what a project
|
|
21
|
+
* that has TypeScript installed — which is most projects with TypeScript in them — gets
|
|
22
|
+
* on top.
|
|
23
|
+
*/
|
|
24
|
+
let cached;
|
|
25
|
+
const SPECIFIER = 'typescript';
|
|
26
|
+
async function loadTypeScript() {
|
|
27
|
+
if (cached !== undefined)
|
|
28
|
+
return cached;
|
|
29
|
+
try {
|
|
30
|
+
const loaded = (await import(/* webpackIgnore: true */ SPECIFIER));
|
|
31
|
+
cached = 'createSourceFile' in loaded ? loaded : loaded.default;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
cached = null;
|
|
35
|
+
}
|
|
36
|
+
return cached;
|
|
37
|
+
}
|
|
38
|
+
/** For tests that need to observe both paths without reinstalling anything. */
|
|
39
|
+
function resetTypeScriptCache() {
|
|
40
|
+
cached = undefined;
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a comparison against a role decides something or displays something.
|
|
3
|
+
*
|
|
4
|
+
* Three separate patches were written for this in one day, each against a shape the
|
|
5
|
+
* previous one did not cover: a chat transcript comparing `m.role` to `"assistant"`, a
|
|
6
|
+
* game comparing `part.role` to `'leftGate'`, and JSX picking an icon from
|
|
7
|
+
* `{m.role === 'editor' ? <Pencil /> : <Eye />}`. All three are the same question, and
|
|
8
|
+
* text cannot ask it: does this comparison guard an action, or choose a label?
|
|
9
|
+
*
|
|
10
|
+
* A syntax tree can. A guard is a condition whose branch leaves — returns, throws, calls
|
|
11
|
+
* `next()`, answers with a status. A label is a condition whose branches are values: a
|
|
12
|
+
* string, an element, an emoji.
|
|
13
|
+
*/
|
|
14
|
+
export type RoleCheckKind = 'guard' | 'label';
|
|
15
|
+
export interface RoleCheck {
|
|
16
|
+
file: string;
|
|
17
|
+
line: number;
|
|
18
|
+
snippet: string;
|
|
19
|
+
kind: RoleCheckKind;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Reads role comparisons structurally, or returns null when it cannot.
|
|
23
|
+
*
|
|
24
|
+
* `null` is not "no roles found" — it is "this question was not asked", and the caller
|
|
25
|
+
* falls back to searching text. The two answers must not be confused: one is evidence,
|
|
26
|
+
* the other is silence, which is the distinction this whole product is built on.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readRoleChecks(root: string, files: string[], limit?: number): Promise<RoleCheck[] | null>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readRoleChecks = readRoleChecks;
|
|
4
|
+
const readTextFileSafe_1 = require("../../utils/readTextFileSafe");
|
|
5
|
+
const loadTypeScript_1 = require("./loadTypeScript");
|
|
6
|
+
const READABLE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
7
|
+
/**
|
|
8
|
+
* The same line the text layer refuses to quote.
|
|
9
|
+
*
|
|
10
|
+
* A parser is happy to read a bundle of forty thousand characters on one line, and a
|
|
11
|
+
* reader is not: the evidence would be a fragment of generated output nobody wrote. The
|
|
12
|
+
* text search skips such a line; so does this, for the same reason and at the same
|
|
13
|
+
* length.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_READABLE_LINE = 500;
|
|
16
|
+
function isGenerated(text) {
|
|
17
|
+
return text.split(/\r?\n/).some((line) => line.length > MAX_READABLE_LINE);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What a branch does when it refuses: the shapes that mean "and stop here".
|
|
21
|
+
*
|
|
22
|
+
* A `return` is not enough on its own. `if (role === 'assistant') { return 'Assistant'; }`
|
|
23
|
+
* returns a label, and reading that as a guard put a chat transcript back where the
|
|
24
|
+
* regexes had it. A refusal returns nothing, or returns the answer to a request; a label
|
|
25
|
+
* returns a value.
|
|
26
|
+
*/
|
|
27
|
+
function leaves(ts, node) {
|
|
28
|
+
let found = false;
|
|
29
|
+
const visit = (child) => {
|
|
30
|
+
if (found)
|
|
31
|
+
return;
|
|
32
|
+
if (ts.isThrowStatement(child)) {
|
|
33
|
+
found = true;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (ts.isReturnStatement(child)) {
|
|
37
|
+
// `return;` stops. `return 'Assistant';` answers with a label.
|
|
38
|
+
if (!child.expression || !isJustAValue(ts, child.expression)) {
|
|
39
|
+
found = true;
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (ts.isCallExpression(child)) {
|
|
44
|
+
const text = child.expression.getText();
|
|
45
|
+
// `next()`, `res.status(403)`, `abort()`, `redirect()` — an answer, not a value.
|
|
46
|
+
if (/(^|\.)(next|abort|redirect|forbid|deny|unauthorized)$/i.test(text) || /\.status$/.test(text)) {
|
|
47
|
+
found = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ts.forEachChild(child, visit);
|
|
52
|
+
};
|
|
53
|
+
visit(node);
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/** A branch that is only a value: a string, an element, a number, an emoji. */
|
|
57
|
+
function isJustAValue(ts, node) {
|
|
58
|
+
return ts.isStringLiteral(node)
|
|
59
|
+
|| ts.isNoSubstitutionTemplateLiteral(node)
|
|
60
|
+
|| ts.isTemplateExpression(node)
|
|
61
|
+
|| ts.isNumericLiteral(node)
|
|
62
|
+
|| ts.isJsxElement(node)
|
|
63
|
+
|| ts.isJsxSelfClosingElement(node)
|
|
64
|
+
|| ts.isJsxFragment(node);
|
|
65
|
+
}
|
|
66
|
+
function mentionsARole(ts, node) {
|
|
67
|
+
if (ts.isPropertyAccessExpression(node))
|
|
68
|
+
return /^roles?$/i.test(node.name.getText());
|
|
69
|
+
if (ts.isIdentifier(node))
|
|
70
|
+
return /^roles?$/i.test(node.getText());
|
|
71
|
+
if (ts.isElementAccessExpression(node))
|
|
72
|
+
return /['"`]roles?['"`]/i.test(node.argumentExpression.getText());
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Classifies every comparison against something called `role` in one file.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately narrow: only `===`, `==`, `!==` and `!=` against a role, which is the
|
|
79
|
+
* shape all three misreadings had. `roles.includes(x)` and `requireRole(...)` are
|
|
80
|
+
* already unambiguous in text and need no tree.
|
|
81
|
+
*/
|
|
82
|
+
function readFile(ts, file, text) {
|
|
83
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
84
|
+
const checks = [];
|
|
85
|
+
const visit = (node) => {
|
|
86
|
+
if (ts.isBinaryExpression(node)
|
|
87
|
+
&& [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken,
|
|
88
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken]
|
|
89
|
+
.includes(node.operatorToken.kind)
|
|
90
|
+
&& (mentionsARole(ts, node.left) || mentionsARole(ts, node.right))) {
|
|
91
|
+
const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
92
|
+
const parent = node.parent;
|
|
93
|
+
let kind = 'label';
|
|
94
|
+
if (parent && ts.isIfStatement(parent) && parent.expression === node) {
|
|
95
|
+
kind = leaves(ts, parent.thenStatement) || (parent.elseStatement && leaves(ts, parent.elseStatement))
|
|
96
|
+
? 'guard'
|
|
97
|
+
: 'label';
|
|
98
|
+
}
|
|
99
|
+
else if (parent && ts.isConditionalExpression(parent) && parent.condition === node) {
|
|
100
|
+
// A ternary whose arms are both values is picking one. Anything else may act.
|
|
101
|
+
kind = isJustAValue(ts, parent.whenTrue) && isJustAValue(ts, parent.whenFalse) ? 'label' : 'guard';
|
|
102
|
+
}
|
|
103
|
+
else if (parent && (ts.isJsxExpression(parent) || ts.isJsxAttribute(parent))) {
|
|
104
|
+
kind = 'label';
|
|
105
|
+
}
|
|
106
|
+
else if (parent && ts.isReturnStatement(parent)) {
|
|
107
|
+
// `return user.role === 'admin'` is a predicate somebody calls to decide.
|
|
108
|
+
kind = 'guard';
|
|
109
|
+
}
|
|
110
|
+
else if (parent && (ts.isArrowFunction(parent) || ts.isParenthesizedExpression(parent))) {
|
|
111
|
+
/**
|
|
112
|
+
* `const canPublish = (member) => member.role === ROLE_EDITOR;`
|
|
113
|
+
*
|
|
114
|
+
* A function whose whole body is the comparison exists to be asked. Reading it
|
|
115
|
+
* as a label lost the one real role check in a fixture written to contain
|
|
116
|
+
* exactly one.
|
|
117
|
+
*/
|
|
118
|
+
kind = 'guard';
|
|
119
|
+
}
|
|
120
|
+
checks.push({
|
|
121
|
+
file,
|
|
122
|
+
line: line + 1,
|
|
123
|
+
snippet: node.getText(source).replace(/\s+/g, ' ').slice(0, 200),
|
|
124
|
+
kind,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
ts.forEachChild(node, visit);
|
|
128
|
+
};
|
|
129
|
+
visit(source);
|
|
130
|
+
return checks;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Reads role comparisons structurally, or returns null when it cannot.
|
|
134
|
+
*
|
|
135
|
+
* `null` is not "no roles found" — it is "this question was not asked", and the caller
|
|
136
|
+
* falls back to searching text. The two answers must not be confused: one is evidence,
|
|
137
|
+
* the other is silence, which is the distinction this whole product is built on.
|
|
138
|
+
*/
|
|
139
|
+
async function readRoleChecks(root, files, limit = 40) {
|
|
140
|
+
const ts = await (0, loadTypeScript_1.loadTypeScript)();
|
|
141
|
+
if (!ts)
|
|
142
|
+
return null;
|
|
143
|
+
const readable = files.filter((file) => READABLE.test(file));
|
|
144
|
+
if (readable.length === 0)
|
|
145
|
+
return [];
|
|
146
|
+
const checks = [];
|
|
147
|
+
for (const file of readable) {
|
|
148
|
+
if (checks.length >= limit)
|
|
149
|
+
break;
|
|
150
|
+
const text = await (0, readTextFileSafe_1.readTextFileSafe)(root, file);
|
|
151
|
+
if (!text || isGenerated(text))
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
checks.push(...readFile(ts, file, text));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// A file the parser refuses is a file this reading does not cover. The text search
|
|
158
|
+
// underneath still sees it.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return checks.slice(0, limit);
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Deterministic CLI and library that analyzes a web application repository and reports how far it is from production-ready for the kind of product it is meant to be.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -48,11 +48,15 @@
|
|
|
48
48
|
"zod": "^3.23.8"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
52
|
+
"typescript": ">=5.0.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependenciesMeta": {
|
|
54
55
|
"@modelcontextprotocol/sdk": {
|
|
55
56
|
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"typescript": {
|
|
59
|
+
"optional": true
|
|
56
60
|
}
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|