@produtype/core 0.3.3 → 0.4.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
@@ -40,6 +40,16 @@ From the report, ProdKit can also build a deterministic remediation plan with ph
40
40
 
41
41
  ## Installation
42
42
 
43
+ ```bash
44
+ npm install -g @produtype/core
45
+ prodkit analyze .
46
+ ```
47
+
48
+ The command is `prodkit`; the package is `@produtype/core`. They differ on purpose:
49
+ `prodkit` on npm is an unrelated and actively maintained package, so this one is
50
+ published under the `@produtype` scope. `npm i prodkit` installs somebody else's
51
+ project.
52
+
43
53
  ### Local development install
44
54
 
45
55
  1. Install dependencies:
@@ -155,7 +165,7 @@ _Generated from the analyzer itself — run `npm run docs:stacks` after changing
155
165
 
156
166
  - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, Litestar, Sanic, Tornado, aiohttp, Starlette, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
157
167
  - **Frontend:** React, Vite, Vue, Nuxt, Svelte, Angular, Astro, Solid, Qwik, Preact, Remix, htmx, Tailwind CSS, Electron
158
- - **Mobile:** Flutter
168
+ - **Mobile:** Flutter, React Native, iOS (native), Android (native)
159
169
  - **Databases:** Postgres, MySQL, SQLite, SQL Server, MongoDB, Redis, Firestore, DynamoDB, Convex
160
170
  - **Hosted data platforms:** Supabase, Firebase, PlanetScale, Neon, Vercel Postgres, Turso, Upstash, DynamoDB, Convex
161
171
  - **ORMs:** Prisma, Drizzle, TypeORM, Sequelize, Knex, MikroORM, Kysely, SQLAlchemy, Tortoise, Peewee
@@ -173,6 +183,9 @@ How some of these are decided:
173
183
  - **ASP.NET Core** — the Microsoft.NET.Sdk.Web SDK attribute
174
184
  - **.NET** — a .csproj with no web SDK
175
185
  - **Flutter** — pubspec.yaml — classified as a client application, not a backend
186
+ - **React Native** — the react-native or expo dependency
187
+ - **iOS (native)** — Info.plist, Package.swift, a Podfile or an .xcodeproj in the tree
188
+ - **Android (native)** — AndroidManifest.xml, or build.gradle in either dialect
176
189
  - **Supabase** — recorded alongside the engine it is — Postgres
177
190
  - **Firebase** — Firestore
178
191
  - **PlanetScale** — MySQL
@@ -365,6 +365,53 @@ async function analyzeProject(projectPath) {
365
365
  dartDeps.push(match[1].toLowerCase());
366
366
  }
367
367
  }
368
+ /**
369
+ * Gradle, read from build.gradle and build.gradle.kts.
370
+ *
371
+ * Both dialects in one reader because the line that matters is nearly the same in
372
+ * each: `implementation 'group:artifact:version'` in Groovy and
373
+ * `implementation("group:artifact:version")` in Kotlin. Only the coordinate is kept —
374
+ * group and artifact, without the version — because every rule downstream asks which
375
+ * library is used, never which release of it.
376
+ *
377
+ * Version catalogs (`libs.androidx.core.ktx`) are deliberately not resolved. They
378
+ * name an alias defined in a TOML file, and following it would mean a second reader
379
+ * for a second format to learn the same fact; a project using them reports fewer
380
+ * libraries rather than wrong ones.
381
+ */
382
+ const gradleDeps = [];
383
+ for (const file of allFiles.filter((f) => /(^|\/)build\.gradle(\.kts)?$/.test(f))) {
384
+ const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
385
+ for (const match of raw.matchAll(/\b(?:implementation|api|compileOnly|runtimeOnly|kapt|ksp|annotationProcessor|testImplementation)\s*[( ]\s*["']([^"']+)["']/g)) {
386
+ const [group, artifact] = match[1].split(':');
387
+ if (group && artifact)
388
+ gradleDeps.push(`${group}:${artifact}`.toLowerCase());
389
+ }
390
+ }
391
+ /**
392
+ * Swift packages, from Package.swift and the Podfile.
393
+ *
394
+ * Package.swift is Swift source rather than data, so what is read is the one shape
395
+ * that is always there: `.package(url: "https://github.com/owner/name.git", ...)`.
396
+ * The owner and repository name are kept, which is how a Swift dependency is
397
+ * identified in practice — nobody says "the Alamofire product of the Alamofire
398
+ * package".
399
+ *
400
+ * CocoaPods is simpler and still in wide use: `pod 'Alamofire'`.
401
+ */
402
+ const swiftDeps = [];
403
+ for (const file of allFiles.filter((f) => /(^|\/)Package\.swift$/.test(f))) {
404
+ const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
405
+ for (const match of raw.matchAll(/\.package\s*\(\s*url:\s*["']https?:\/\/[^"']*?\/([^/"']+?)\/([^/"']+?)(?:\.git)?["']/g)) {
406
+ swiftDeps.push(`${match[1]}/${match[2]}`.toLowerCase());
407
+ }
408
+ }
409
+ for (const file of allFiles.filter((f) => /(^|\/)Podfile$/.test(f))) {
410
+ const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
411
+ for (const match of raw.matchAll(/^\s*pod\s+["']([^"'/]+)/gm)) {
412
+ swiftDeps.push(match[1].toLowerCase());
413
+ }
414
+ }
368
415
  /**
369
416
  * .csproj, which is XML rather than JSON or one-entry-per-line.
370
417
  *
@@ -458,6 +505,8 @@ async function analyzeProject(projectPath) {
458
505
  dotnetDeps: unique(dotnetDeps),
459
506
  dotnetWebSdk,
460
507
  dartDeps: unique(dartDeps),
508
+ gradleDeps: unique(gradleDeps),
509
+ swiftDeps: unique(swiftDeps),
461
510
  npmDeps,
462
511
  workspaces,
463
512
  };
@@ -29,6 +29,19 @@ export declare const PHP_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
29
29
  export declare const PYTHON_BACKEND_FRAMEWORKS: Array<[string, string]>;
30
30
  /** Frontend frameworks settled by their signature dependency. */
31
31
  export declare const FRONTEND_FRAMEWORKS: Array<[string, string[]]>;
32
+ /**
33
+ * Source languages, and the extensions that say so.
34
+ *
35
+ * `deriveLanguages` knew about TypeScript, JavaScript and Python, so a Go service or a
36
+ * Kotlin app reported `languages: []` while the README and the website both claimed
37
+ * twelve. One table, read by the detector and by the catalogue, is what stops the
38
+ * claim and the code disagreeing again.
39
+ */
40
+ export declare const LANGUAGES: Array<{
41
+ id: string;
42
+ label: string;
43
+ extensions: RegExp;
44
+ }>;
32
45
  export interface CatalogueEntry {
33
46
  /** The identifier the report uses. */
34
47
  id: string;
@@ -15,7 +15,7 @@
15
15
  * that loses users.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.FRONTEND_FRAMEWORKS = exports.PYTHON_BACKEND_FRAMEWORKS = exports.PHP_BACKEND_FRAMEWORKS = exports.RUBY_BACKEND_FRAMEWORKS = exports.GO_BACKEND_FRAMEWORKS = exports.NODE_BACKEND_FRAMEWORKS = void 0;
18
+ exports.LANGUAGES = exports.FRONTEND_FRAMEWORKS = exports.PYTHON_BACKEND_FRAMEWORKS = exports.PHP_BACKEND_FRAMEWORKS = exports.RUBY_BACKEND_FRAMEWORKS = exports.GO_BACKEND_FRAMEWORKS = exports.NODE_BACKEND_FRAMEWORKS = void 0;
19
19
  exports.labelFor = labelFor;
20
20
  exports.hasLabel = hasLabel;
21
21
  exports.supportedStacks = supportedStacks;
@@ -85,6 +85,28 @@ exports.FRONTEND_FRAMEWORKS = [
85
85
  ['remix', ['@remix-run/react']],
86
86
  ['htmx', ['htmx.org']],
87
87
  ];
88
+ /**
89
+ * Source languages, and the extensions that say so.
90
+ *
91
+ * `deriveLanguages` knew about TypeScript, JavaScript and Python, so a Go service or a
92
+ * Kotlin app reported `languages: []` while the README and the website both claimed
93
+ * twelve. One table, read by the detector and by the catalogue, is what stops the
94
+ * claim and the code disagreeing again.
95
+ */
96
+ exports.LANGUAGES = [
97
+ { id: 'typescript', label: 'TypeScript', extensions: /\.(ts|tsx)$/ },
98
+ { id: 'javascript', label: 'JavaScript', extensions: /\.(js|jsx|mjs|cjs)$/ },
99
+ { id: 'python', label: 'Python', extensions: /\.py$/ },
100
+ { id: 'php', label: 'PHP', extensions: /\.php$/ },
101
+ { id: 'go', label: 'Go', extensions: /\.go$/ },
102
+ { id: 'ruby', label: 'Ruby', extensions: /\.rb$/ },
103
+ { id: 'java', label: 'Java', extensions: /\.java$/ },
104
+ { id: 'csharp', label: 'C#', extensions: /\.cs$/ },
105
+ { id: 'rust', label: 'Rust', extensions: /\.rs$/ },
106
+ { id: 'kotlin', label: 'Kotlin', extensions: /\.(kt|kts)$/ },
107
+ { id: 'swift', label: 'Swift', extensions: /\.swift$/ },
108
+ { id: 'dart', label: 'Dart', extensions: /\.dart$/ },
109
+ ];
88
110
  /** Display names for ids the tables above produce. */
89
111
  const LABELS = {
90
112
  next: 'Next.js',
@@ -133,6 +155,9 @@ const LABELS = {
133
155
  tailwindcss: 'Tailwind CSS',
134
156
  electron: 'Electron',
135
157
  flutter: 'Flutter',
158
+ 'react-native': 'React Native',
159
+ ios: 'iOS (native)',
160
+ android: 'Android (native)',
136
161
  express: 'Express',
137
162
  flask: 'Flask',
138
163
  fastapi: 'FastAPI',
@@ -233,6 +258,17 @@ function supportedStacks() {
233
258
  label: 'Flutter',
234
259
  detectedFrom: 'pubspec.yaml — classified as a client application, not a backend',
235
260
  },
261
+ { id: 'react-native', label: 'React Native', detectedFrom: 'the react-native or expo dependency' },
262
+ {
263
+ id: 'ios',
264
+ label: 'iOS (native)',
265
+ detectedFrom: 'Info.plist, Package.swift, a Podfile or an .xcodeproj in the tree',
266
+ },
267
+ {
268
+ id: 'android',
269
+ label: 'Android (native)',
270
+ detectedFrom: 'AndroidManifest.xml, or build.gradle in either dialect',
271
+ },
236
272
  ],
237
273
  databases: entries(['postgres', 'mysql', 'sqlite', 'sqlserver', 'mongodb', 'redis', 'firestore', 'dynamodb', 'convex']),
238
274
  dataPlatforms: [
@@ -258,6 +294,6 @@ function supportedStacks() {
258
294
  'tortoise',
259
295
  'peewee',
260
296
  ]),
261
- languages: entries(['typescript', 'javascript', 'python', 'php', 'go', 'ruby', 'java', 'csharp', 'rust', 'kotlin', 'swift', 'dart']).map((entry) => ({ ...entry, label: labelFor(entry.id) === entry.id ? entry.id.replace(/^\w/, (c) => c.toUpperCase()) : labelFor(entry.id) })),
297
+ languages: exports.LANGUAGES.map(({ id, label }) => ({ id, label })),
262
298
  };
263
299
  }
@@ -32,6 +32,10 @@ export interface DetectContext {
32
32
  dotnetWebSdk: boolean;
33
33
  /** Package names from pubspec.yaml, lowercase. */
34
34
  dartDeps: string[];
35
+ /** Gradle coordinates as `group:artifact`, without the version, lowercase. */
36
+ gradleDeps: string[];
37
+ /** Swift packages as `owner/repo`, and CocoaPods names, lowercase. */
38
+ swiftDeps: string[];
35
39
  /** Lowercased combined dependency map (deps + devDeps). */
36
40
  npmDeps: Record<string, string>;
37
41
  workspaces: WorkspaceManifest[];
@@ -54,5 +58,7 @@ export declare function hasAnyRubyDep(ctx: DetectContext, names: string[]): stri
54
58
  * `Npgsql.EntityFrameworkCore.PostgreSQL`, and a rule should be able to ask for
55
59
  * `Npgsql` without listing every provider package that starts with it.
56
60
  */
61
+ export declare function hasAnyGradleDep(ctx: DetectContext, names: string[]): string[];
62
+ export declare function hasAnySwiftDep(ctx: DetectContext, names: string[]): string[];
57
63
  export declare function hasAnyDartDep(ctx: DetectContext, names: string[]): string[];
58
64
  export declare function hasAnyDotnetDep(ctx: DetectContext, names: string[]): string[];
@@ -8,6 +8,8 @@ exports.hasPhpDep = hasPhpDep;
8
8
  exports.hasAnyPhpDep = hasAnyPhpDep;
9
9
  exports.hasAnyGoDep = hasAnyGoDep;
10
10
  exports.hasAnyRubyDep = hasAnyRubyDep;
11
+ exports.hasAnyGradleDep = hasAnyGradleDep;
12
+ exports.hasAnySwiftDep = hasAnySwiftDep;
11
13
  exports.hasAnyDartDep = hasAnyDartDep;
12
14
  exports.hasAnyDotnetDep = hasAnyDotnetDep;
13
15
  function hasDep(ctx, name) {
@@ -55,6 +57,16 @@ function hasAnyRubyDep(ctx, names) {
55
57
  * `Npgsql.EntityFrameworkCore.PostgreSQL`, and a rule should be able to ask for
56
58
  * `Npgsql` without listing every provider package that starts with it.
57
59
  */
60
+ function hasAnyGradleDep(ctx, names) {
61
+ // Substring rather than equality: a coordinate is `androidx.room:room-runtime`, and
62
+ // a rule asks about `androidx.room` without wanting to name every artifact in it.
63
+ const wanted = names.map((name) => name.toLowerCase());
64
+ return ctx.gradleDeps.filter((dep) => wanted.some((name) => dep.includes(name)));
65
+ }
66
+ function hasAnySwiftDep(ctx, names) {
67
+ const wanted = names.map((name) => name.toLowerCase());
68
+ return ctx.swiftDeps.filter((dep) => wanted.some((name) => dep.includes(name)));
69
+ }
58
70
  function hasAnyDartDep(ctx, names) {
59
71
  return hasAnyIn(ctx.dartDeps, names);
60
72
  }
@@ -38,6 +38,35 @@ const textSearch_1 = require("../utils/textSearch");
38
38
  /** Files that say which platform this is, without reading their contents. */
39
39
  const IOS_MARKERS = [/(^|\/)Info\.plist$/i, /(^|\/)Podfile$/, /\.xcodeproj\//, /(^|\/)Package\.swift$/];
40
40
  const ANDROID_MARKERS = [/(^|\/)AndroidManifest\.xml$/i, /(^|\/)build\.gradle(\.kts)?$/];
41
+ /** Gradle coordinates that put a secret in the Android keystore rather than in a file. */
42
+ const SECURE_STORAGE_GRADLE = ['androidx.security:security-crypto', 'com.scottyab:secure-preferences'];
43
+ /**
44
+ * Swift keychain wrappers, named both ways.
45
+ *
46
+ * Swift Package Manager identifies a dependency as `owner/repo` and CocoaPods as a
47
+ * bare pod name, so the same library arrives under two spellings and a list holding
48
+ * only one of them finds it only half the time — which is how a Podfile declaring
49
+ * KeychainAccess came back as "no secure storage".
50
+ */
51
+ const SECURE_STORAGE_SWIFT = [
52
+ 'kishikawakatsumi/keychainaccess',
53
+ 'keychainaccess',
54
+ 'evgenyneu/keychain-swift',
55
+ 'keychainswift',
56
+ 'square/valet',
57
+ 'valet',
58
+ ];
59
+ /** Local databases, by platform, that let an app open without a network. */
60
+ const OFFLINE_GRADLE = ['androidx.room', 'io.realm', 'io.objectbox', 'com.squareup.sqldelight', 'app.cash.sqldelight'];
61
+ const OFFLINE_SWIFT = [
62
+ 'groue/grdb.swift',
63
+ 'grdb.swift',
64
+ 'stephencelis/sqlite.swift',
65
+ 'sqlite.swift',
66
+ 'realm/realm-swift',
67
+ 'realm/realm-cocoa',
68
+ 'realmswift',
69
+ ];
41
70
  /** Dependencies that put a secret somewhere the operating system protects. */
42
71
  const SECURE_STORAGE_DEPS = [
43
72
  'flutter_secure_storage',
@@ -160,9 +189,16 @@ async function detectMobile(ctx) {
160
189
  }
161
190
  const secureStorage = (0, detectContext_1.hasAnyDep)(ctx, SECURE_STORAGE_DEPS);
162
191
  const secureStorageDart = (0, detectContext_1.hasAnyDartDep)(ctx, ['flutter_secure_storage']);
192
+ const secureStorageNative = [
193
+ ...(0, detectContext_1.hasAnyGradleDep)(ctx, SECURE_STORAGE_GRADLE),
194
+ ...(0, detectContext_1.hasAnySwiftDep)(ctx, SECURE_STORAGE_SWIFT),
195
+ ];
163
196
  const keychainInSource = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/KeychainAccess/, /kSecClass/, /EncryptedSharedPreferences/, /AndroidKeyStore/, /SecItemAdd/], 3);
164
197
  const credentialEvidence = [
165
- ...[...secureStorage, ...secureStorageDart].map((dep) => ({ type: 'dependency', value: dep })),
198
+ ...[...secureStorage, ...secureStorageDart, ...secureStorageNative].map((dep) => ({
199
+ type: 'dependency',
200
+ value: dep,
201
+ })),
166
202
  ...keychainInSource.map((match) => ({
167
203
  type: 'snippet',
168
204
  value: match.snippet,
@@ -170,7 +206,12 @@ async function detectMobile(ctx) {
170
206
  line: match.line,
171
207
  })),
172
208
  ];
173
- const offlineDeps = [...(0, detectContext_1.hasAnyDep)(ctx, OFFLINE_DEPS), ...(0, detectContext_1.hasAnyDartDep)(ctx, OFFLINE_DEPS)];
209
+ const offlineDeps = [
210
+ ...(0, detectContext_1.hasAnyDep)(ctx, OFFLINE_DEPS),
211
+ ...(0, detectContext_1.hasAnyDartDep)(ctx, OFFLINE_DEPS),
212
+ ...(0, detectContext_1.hasAnyGradleDep)(ctx, OFFLINE_GRADLE),
213
+ ...(0, detectContext_1.hasAnySwiftDep)(ctx, OFFLINE_SWIFT),
214
+ ];
174
215
  /**
175
216
  * A local database is the strong signal; knowing the network dropped is the weak
176
217
  * one. Both are recorded, because an app that stores nothing but tells the user the
@@ -3,18 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.deriveLanguages = deriveLanguages;
4
4
  exports.buildStackInfo = buildStackInfo;
5
5
  exports.mergeDetectors = mergeDetectors;
6
+ const catalogue_1 = require("./catalogue");
6
7
  function unique(items) {
7
8
  return Array.from(new Set(items));
8
9
  }
9
10
  function deriveLanguages(files) {
10
- const out = [];
11
- if (files.some((f) => /\.(ts|tsx)$/.test(f)))
12
- out.push('typescript');
13
- if (files.some((f) => /\.(js|jsx|mjs|cjs)$/.test(f)))
14
- out.push('javascript');
15
- if (files.some((f) => /\.py$/.test(f)))
16
- out.push('python');
17
- return unique(out);
11
+ // Read from the catalogue rather than from three hand-written lines. It knew about
12
+ // TypeScript, JavaScript and Python only, so a Go service reported no languages at
13
+ // all while the README listed twelve.
14
+ return unique(catalogue_1.LANGUAGES.filter(({ extensions }) => files.some((file) => extensions.test(file))).map(({ id }) => id));
18
15
  }
19
16
  function buildStackInfo(input) {
20
17
  return {
@@ -108,14 +108,21 @@ function buildReport(analysis, options) {
108
108
  const combinedScore = expectationScore === undefined
109
109
  ? observedScore
110
110
  : Math.max(0, Math.min(100, Math.round((observedScore * 0.6) + (expectationScore * 0.4))));
111
+ // A recognised mobile platform counts as a stack signal. Without this an Android
112
+ // project with an AndroidManifest.xml and a build.gradle was reported as
113
+ // "inconclusive — no package manifests found", which is the analyzer saying it
114
+ // understood nothing about a project it had in fact identified, and capping the score
115
+ // at 39 on that basis.
116
+ const mobileDetected = analysis.detectors['mobile.platform']?.present === true;
111
117
  const stackDetected = analysis.stack.frontend.length > 0
112
118
  || analysis.stack.backend.length > 0
113
- || analysis.stack.databases.length > 0;
119
+ || analysis.stack.databases.length > 0
120
+ || mobileDetected;
114
121
  const inconclusive = !stackDetected && analysis.workspaceStacks.length === 0;
115
122
  const inconclusiveReasons = [];
116
123
  if (inconclusive) {
117
124
  inconclusiveReasons.push('No frontend, backend, or database stack signals were detected.');
118
- inconclusiveReasons.push('No package manifests (package.json, requirements.txt, pyproject.toml) were found.');
125
+ inconclusiveReasons.push('No package manifest was found in any format this analyzer reads.');
119
126
  if (analysis.files.source.length === 0) {
120
127
  inconclusiveReasons.push('No recognizable source files were found.');
121
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.3.3",
3
+ "version": "0.4.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": {