@produtype/core 0.32.0 → 0.34.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.
package/README.md CHANGED
@@ -174,7 +174,7 @@ prodkit plan ../my-app --output prodkit-plan.md
174
174
 
175
175
  _Generated from the analyzer itself — run `npm run docs:stacks` after changing a detector._
176
176
 
177
- - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, Litestar, Sanic, Tornado, aiohttp, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
177
+ - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, aiohttp, Litestar, Sanic, Tornado, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
178
178
  - **Frontend:** React, Vite, Vue, Nuxt, Svelte, Angular, Astro, Solid, Qwik, Preact, Remix, htmx, Tailwind CSS, Electron
179
179
  - **Mobile:** Flutter, React Native, iOS (native), Android (native)
180
180
  - **Databases:** Postgres, MySQL, SQLite, SQL Server, MongoDB, Redis, Firestore, DynamoDB, Convex
@@ -188,6 +188,7 @@ How some of these are decided:
188
188
  - **Django** — manage.py, settings.py and urls.py together
189
189
  - **Flask** — the dependency, or an import in the source
190
190
  - **FastAPI** — the dependency, or an import in the source
191
+ - **aiohttp** — aiohttp.web in the source — the dependency alone is a client
191
192
  - **Go** — a go.mod with no framework in it — net/http is a real answer
192
193
  - **Ruby** — a Gemfile with no web framework in it
193
194
  - **PHP** — PHP sources with no framework in composer.json
@@ -87,7 +87,24 @@ function parseRequirements(text) {
87
87
  function normalizePyDepSpec(spec) {
88
88
  return spec.split(/[\s<>=!~;[(]/)[0].trim().toLowerCase();
89
89
  }
90
+ /**
91
+ * What a Python package installs by default, and what it merely offers.
92
+ *
93
+ * `[project.optional-dependencies]` and `[dependency-groups]` are extras: a library that
94
+ * integrates with FastAPI declares it there, and nobody installing the library gets a
95
+ * web server. langchain and llama_index declare four web frameworks between them that
96
+ * way, and were read as web applications — costing langchain the `library` profile and
97
+ * earning it fifteen high findings about GDPR, billing and tenant isolation.
98
+ *
99
+ * Mirrors what `dependencies` and `devDependencies` already do for npm.
100
+ */
101
+ function parsePyprojectRuntime(text) {
102
+ return parsePyprojectSections(text, true);
103
+ }
90
104
  function parsePyproject(text) {
105
+ return parsePyprojectSections(text, false);
106
+ }
107
+ function parsePyprojectSections(text, runtimeOnly) {
91
108
  if (!text)
92
109
  return [];
93
110
  const deps = [];
@@ -105,8 +122,8 @@ function parsePyproject(text) {
105
122
  }
106
123
  // Poetry-style tables: each key of the section is a dependency name.
107
124
  const isPoetryDepsSection = section === 'tool.poetry.dependencies'
108
- || section === 'tool.poetry.dev-dependencies'
109
- || /^tool\.poetry\.group\.[^.]+\.dependencies$/.test(section);
125
+ || (!runtimeOnly && (section === 'tool.poetry.dev-dependencies'
126
+ || /^tool\.poetry\.group\.[^.]+\.dependencies$/.test(section)));
110
127
  if (isPoetryDepsSection) {
111
128
  const match = line.match(/^([A-Za-z0-9_.-]+)\s*=/);
112
129
  if (match && match[1].toLowerCase() !== 'python')
@@ -114,9 +131,10 @@ function parsePyproject(text) {
114
131
  continue;
115
132
  }
116
133
  // PEP 621 / uv style: dependency specs live inside string arrays.
117
- const isDepArraySection = section === 'project.optional-dependencies'
118
- || section === 'dependency-groups'
119
- || section === 'tool.uv';
134
+ const isDepArraySection = !runtimeOnly
135
+ && (section === 'project.optional-dependencies'
136
+ || section === 'dependency-groups'
137
+ || section === 'tool.uv');
120
138
  if (section === 'project' || isDepArraySection || inDependencyArray) {
121
139
  if (line.includes('include-group'))
122
140
  continue;
@@ -142,7 +160,10 @@ function isTestOrExamplePath(file) {
142
160
  // `__mocks__` was missing, and a mock is the most misleading file in a repository:
143
161
  // `application_fee_percent: null` inside a Stripe fixture made an open-source CRM read
144
162
  // as a marketplace taking a cut. A field set to null is evidence of absence.
145
- return /(^|\/)(__tests__|__mocks__|mocks?|tests?|test-data|fixtures|frontend-example)(\/|$)/i.test(file)
163
+ // `fixtures` was listed and `fixture` was not, so `extra/fixture/authsources.php`
164
+ // made PHP one of the languages of an Elixir analytics product — and of cal.com,
165
+ // which is TypeScript.
166
+ return /(^|\/)(__tests__|__mocks__|mocks?|tests?|test-data|fixtures?|frontend-example)(\/|$)/i.test(file)
146
167
  || /(^|\/)test[-_][^/]+\.(ts|tsx|js|jsx|mjs|cjs|py)$/i.test(file)
147
168
  /**
148
169
  * The other half of the convention.
@@ -366,10 +387,16 @@ async function analyzeProject(projectPath) {
366
387
  requirementsDeps: parseRequirements(reqText),
367
388
  pyprojectPath: pyprojectFiles.includes(pyprojectPath) ? pyprojectPath : undefined,
368
389
  pyprojectDeps: parsePyproject(pyprojectText),
390
+ pyprojectRuntimeDeps: parsePyprojectRuntime(pyprojectText),
369
391
  lockfiles: wsLockfiles,
370
392
  });
371
393
  }
372
394
  const pythonDeps = unique(workspaces.flatMap((w) => [...w.requirementsDeps, ...w.pyprojectDeps]));
395
+ /**
396
+ * `requirements.txt` has no notion of an extra, so everything in it counts as shipped.
397
+ * Only pyproject distinguishes the two.
398
+ */
399
+ const runtimePythonDeps = unique(workspaces.flatMap((w) => [...w.requirementsDeps, ...w.pyprojectRuntimeDeps]));
373
400
  /**
374
401
  * Composer requirements. A published PHP application in the corpus reported a
375
402
  * backend of "unknown" because nothing read this file, while 57 source files sat
@@ -577,6 +604,14 @@ async function analyzeProject(projectPath) {
577
604
  const imported = await (0, importedDependencies_1.pythonImports)(root, sourceFiles);
578
605
  if (imported.length > 0) {
579
606
  pythonDeps.push(...imported);
607
+ /**
608
+ * An import is use, not an offer.
609
+ *
610
+ * These count as shipped as well as present. A repository with no manifest at all
611
+ * — one `main.py` that imports streamlit — declares nothing optional, and treating
612
+ * its imports as extras made a Streamlit application unreadable.
613
+ */
614
+ runtimePythonDeps.push(...imported);
580
615
  inferredDependencySources.push('Python imports');
581
616
  }
582
617
  }
@@ -610,6 +645,7 @@ async function analyzeProject(projectPath) {
610
645
  root,
611
646
  files: { all: allFiles, source: sourceFiles, config: configFiles, unreadable: unreadableLanguages(allFiles) },
612
647
  runtimeNpmDeps,
648
+ runtimePythonDeps,
613
649
  packageJson,
614
650
  pythonDeps,
615
651
  phpDeps: unique(phpDeps),
@@ -711,7 +747,16 @@ async function analyzeProject(projectPath) {
711
747
  : []),
712
748
  ],
713
749
  workspaces: workspaceStacks,
714
- files: allFiles,
750
+ /**
751
+ * The files this reading actually covers, not every file on disk.
752
+ *
753
+ * Built from `allFiles`, a single `extra/fixture/authsources.php` made PHP one of
754
+ * the languages of an Elixir analytics product and of cal.com, which is
755
+ * TypeScript. The languages a report names should be the ones it read; test
756
+ * fixtures and generated output are excluded from the reading and belong out of
757
+ * this list for the same reason.
758
+ */
759
+ files: sourceFiles,
715
760
  }),
716
761
  packageJson,
717
762
  pythonDeps,
@@ -69,7 +69,6 @@ exports.PYTHON_BACKEND_FRAMEWORKS = [
69
69
  ['litestar', 'litestar'],
70
70
  ['sanic', 'sanic'],
71
71
  ['tornado', 'tornado'],
72
- ['aiohttp', 'aiohttp'],
73
72
  ['starlette', 'starlette'],
74
73
  /**
75
74
  * These serve an application over HTTP without calling themselves web frameworks,
@@ -248,6 +247,16 @@ function supportedStacks() {
248
247
  { id: 'django', label: 'Django', detectedFrom: 'manage.py, settings.py and urls.py together' },
249
248
  { id: 'flask', label: 'Flask', detectedFrom: 'the dependency, or an import in the source' },
250
249
  { id: 'fastapi', label: 'FastAPI', detectedFrom: 'the dependency, or an import in the source' },
250
+ {
251
+ id: 'aiohttp',
252
+ label: 'aiohttp',
253
+ /**
254
+ * Listed separately because the dependency alone does not settle it: aiohttp is
255
+ * a client as often as a server, and thousands of packages depend on it to make
256
+ * requests. `aiohttp.web` is what says it is being served.
257
+ */
258
+ detectedFrom: 'aiohttp.web in the source — the dependency alone is a client',
259
+ },
251
260
  ...entries(exports.PYTHON_BACKEND_FRAMEWORKS.map(([id]) => id)),
252
261
  ...entries(exports.GO_BACKEND_FRAMEWORKS.map(([id]) => id)),
253
262
  { id: 'go', label: 'Go', detectedFrom: 'a go.mod with no framework in it — net/http is a real answer' },
@@ -230,8 +230,29 @@ async function detectAuth(ctx) {
230
230
  * `const teamId = 'W7LPPWA48L'` in its notarization script.
231
231
  */
232
232
  const STRONG_TENANCY = [/organizationId/i, /organization_id/i, /tenantId/i, /tenant_id/i];
233
- const WEAK_TENANCY = [/workspaceId/i, /workspace_id/i, /companyId/i];
234
- const strongOrganization = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, STRONG_TENANCY, 25);
233
+ const WEAK_TENANCY = [/workspaceId/i, /workspace_id/i, /companyId/i, /teamId/i, /team_id/i];
234
+ /**
235
+ * A team that has members is an account.
236
+ *
237
+ * "Team" is the third word products use for a tenant, after organization and
238
+ * workspace — and unlike those two it also means a team: a sports application has
239
+ * teams and no tenants. Documenso is the case that made it matter: `teamId` in three
240
+ * hundred and eighty-eight files, `TeamMember` in seventy-nine, `organizationId` in
241
+ * none, and no tenancy detected at all.
242
+ *
243
+ * The pairing is what disambiguates. A membership table beside the team turns a
244
+ * domain entity into an account boundary, which is the same reasoning the detector
245
+ * already applies to weak words: never on its own, always with something that means
246
+ * only one thing.
247
+ */
248
+ const teamMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/teamMember/i, /team_member/i, /teamMembership/i], 10);
249
+ const teamAsTenant = teamMembership.length > 0
250
+ ? await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/teamId/i, /team_id/i], 25)
251
+ : [];
252
+ const strongOrganization = [
253
+ ...(await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, STRONG_TENANCY, 25)),
254
+ ...teamAsTenant,
255
+ ];
235
256
  const weakOrganization = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, WEAK_TENANCY, 25);
236
257
  // A weak word never stands on its own, however many files it appears in. Bruno says
237
258
  // `workspaceId` in three — it has workspaces, and they are local folders, not
@@ -248,7 +269,10 @@ async function detectAuth(ctx) {
248
269
  * already states the principle: a weak word never stands on its own, however many
249
270
  * files it appears in.
250
271
  */
251
- const strongMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, STRONG_TENANCY, 25);
272
+ const strongMembership = [
273
+ ...(await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, STRONG_TENANCY, 25)),
274
+ ...teamAsTenant,
275
+ ];
252
276
  const weakMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/memberId/i, ...WEAK_TENANCY], 25);
253
277
  const membershipSignals = strongMembership.length > 0 ? [...strongMembership, ...weakMembership] : [];
254
278
  const b2bSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
@@ -150,14 +150,40 @@ async function detectBackend(ctx) {
150
150
  }
151
151
  // Python backend frameworks detected purely from dependencies. Django, Flask and
152
152
  // FastAPI have their own blocks below because each also has a source-level fallback.
153
+ /**
154
+ * Shipped, not offered as an extra — the Python half of the same rule.
155
+ *
156
+ * llama_index declares tornado, starlette, flask and fastapi among its optional
157
+ * integrations, and was read as running all four.
158
+ */
153
159
  for (const [framework, dep] of catalogue_1.PYTHON_BACKEND_FRAMEWORKS) {
154
- if ((0, detectContext_1.hasPyDep)(ctx, dep)) {
160
+ if ((0, detectContext_1.hasRuntimePyDep)(ctx, dep)) {
155
161
  frameworks.push(framework);
156
162
  evidence.push({ type: 'dependency', value: dep });
157
163
  }
158
164
  }
165
+ /**
166
+ * aiohttp is a client as often as it is a server.
167
+ *
168
+ * Thousands of packages depend on it to make HTTP requests; langchain is one, and was
169
+ * read as having a backend because of it — which cost it the `library` profile and
170
+ * earned it fifteen high findings about GDPR, billing and tenant isolation. The
171
+ * dependency says the library is present. Only `aiohttp.web` says it is being served.
172
+ *
173
+ * The source alone is enough, as elsewhere: a project that serves without declaring
174
+ * the dependency is still serving.
175
+ */
176
+ if ((0, detectContext_1.hasRuntimePyDep)(ctx, 'aiohttp')) {
177
+ const serving = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source.filter((file) => /\.py$/.test(file)), [/aiohttp\.web/, /web\.Application\(/, /web\.RouteTableDef/, /from aiohttp import web/], 3);
178
+ if (serving.length) {
179
+ frameworks.push('aiohttp');
180
+ for (const match of serving) {
181
+ evidence.push({ type: 'snippet', value: match.snippet, file: match.file, line: match.line });
182
+ }
183
+ }
184
+ }
159
185
  // Flask
160
- if ((0, detectContext_1.hasPyDep)(ctx, 'flask')) {
186
+ if ((0, detectContext_1.hasRuntimePyDep)(ctx, 'flask')) {
161
187
  frameworks.push('flask');
162
188
  evidence.push({ type: 'dependency', value: 'flask' });
163
189
  }
@@ -171,7 +197,7 @@ async function detectBackend(ctx) {
171
197
  }
172
198
  }
173
199
  // FastAPI
174
- if ((0, detectContext_1.hasPyDep)(ctx, 'fastapi')) {
200
+ if ((0, detectContext_1.hasRuntimePyDep)(ctx, 'fastapi')) {
175
201
  frameworks.push('fastapi');
176
202
  evidence.push({ type: 'dependency', value: 'fastapi' });
177
203
  }
@@ -192,7 +218,7 @@ async function detectBackend(ctx) {
192
218
  const settingsFile = ctx.files.all.find((f) => f.endsWith('settings.py'));
193
219
  if (settingsFile)
194
220
  djangoSignals.push({ type: 'file', value: settingsFile });
195
- if ((0, detectContext_1.hasPyDep)(ctx, 'django'))
221
+ if ((0, detectContext_1.hasRuntimePyDep)(ctx, 'django'))
196
222
  djangoSignals.push({ type: 'dependency', value: 'django' });
197
223
  if (ctx.files.all.some((f) => f.endsWith('urls.py'))) {
198
224
  djangoSignals.push({ type: 'file', value: ctx.files.all.find((f) => f.endsWith('urls.py')) });
@@ -7,6 +7,8 @@ export interface WorkspaceManifest {
7
7
  requirementsDeps: string[];
8
8
  pyprojectPath?: string;
9
9
  pyprojectDeps: string[];
10
+ /** Of those, the ones installed by default rather than offered as an extra. */
11
+ pyprojectRuntimeDeps: string[];
10
12
  lockfiles: string[];
11
13
  }
12
14
  export interface DetectContext {
@@ -47,6 +49,14 @@ export interface DetectContext {
47
49
  * a backend, which kept it out of the `library` profile it plainly belongs to.
48
50
  */
49
51
  runtimeNpmDeps: Record<string, string>;
52
+ /**
53
+ * Python packages installed by default, without the extras.
54
+ *
55
+ * A library that integrates with FastAPI declares it under
56
+ * `[project.optional-dependencies]`, and nobody installing the library gets a web
57
+ * server. Used for the same single question as `runtimeNpmDeps`.
58
+ */
59
+ runtimePythonDeps: string[];
50
60
  workspaces: WorkspaceManifest[];
51
61
  }
52
62
  export declare function hasDep(ctx: DetectContext, name: string): boolean;
@@ -57,6 +67,8 @@ export declare function hasDep(ctx: DetectContext, name: string): boolean;
57
67
  * question "does this repository serve requests" is answered by what it ships.
58
68
  */
59
69
  export declare function hasRuntimeDep(ctx: DetectContext, name: string): boolean;
70
+ /** The Python counterpart: shipped, not merely offered as an extra. */
71
+ export declare function hasRuntimePyDep(ctx: DetectContext, name: string): boolean;
60
72
  export declare function hasAnyDep(ctx: DetectContext, names: string[]): string[];
61
73
  export declare function hasPyDep(ctx: DetectContext, name: string): boolean;
62
74
  export declare function hasAnyPyDep(ctx: DetectContext, names: string[]): string[];
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hasDep = hasDep;
4
4
  exports.hasRuntimeDep = hasRuntimeDep;
5
+ exports.hasRuntimePyDep = hasRuntimePyDep;
5
6
  exports.hasAnyDep = hasAnyDep;
6
7
  exports.hasPyDep = hasPyDep;
7
8
  exports.hasAnyPyDep = hasAnyPyDep;
@@ -25,6 +26,10 @@ function hasDep(ctx, name) {
25
26
  function hasRuntimeDep(ctx, name) {
26
27
  return Object.prototype.hasOwnProperty.call(ctx.runtimeNpmDeps, name.toLowerCase());
27
28
  }
29
+ /** The Python counterpart: shipped, not merely offered as an extra. */
30
+ function hasRuntimePyDep(ctx, name) {
31
+ return ctx.runtimePythonDeps.includes(name.toLowerCase());
32
+ }
28
33
  function hasAnyDep(ctx, names) {
29
34
  return names.filter((n) => hasDep(ctx, n));
30
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.32.0",
3
+ "version": "0.34.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": {