@produtype/core 0.26.1 → 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.
@@ -156,15 +157,34 @@ async function detectAuth(ctx) {
156
157
  ], 20);
157
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);
158
159
  const sessionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/session/i, /cookie/i, /jwt/i, /refresh\s*token/i, /httpOnly/i], 25);
159
- const roleSignals = excludeChatTurnRoles(await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
160
- /requireRole/i,
161
- /isAdmin/i,
162
- /SUPER_ADMIN/i,
163
- /req\.user\.role/i,
164
- /user\.role/i,
165
- /role\s*===/i,
166
- /roles\.includes\(/i,
167
- ], 20));
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);
168
188
  const permissionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/requirePermission/i, /permission_classes/i, /permissions\.py/i, /authorize\(/i, /\bcan\(/i], 20);
169
189
  const resourceLevelSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
170
190
  /requirePermission/i,
@@ -0,0 +1,4 @@
1
+ import type * as TypeScriptApi from 'typescript';
2
+ export declare function loadTypeScript(): Promise<typeof TypeScriptApi | null>;
3
+ /** For tests that need to observe both paths without reinstalling anything. */
4
+ export declare function resetTypeScriptCache(): void;
@@ -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.26.1",
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": {