@produtype/core 0.74.0 → 0.76.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.
@@ -651,6 +651,25 @@ async function analyzeProject(projectPath) {
651
651
  swiftDeps.push(match[1].toLowerCase());
652
652
  }
653
653
  }
654
+ /**
655
+ * Package.resolved, which is where a Swift project's dependencies are actually
656
+ * legible.
657
+ *
658
+ * `Package.swift` is read above, and on a real application it usually declares the
659
+ * modules of that package rather than the whole tree: WordPress-iOS resolves 40-odd
660
+ * packages and `sentry-cocoa` — its crash reporter — appears only here. The lockfile
661
+ * is data rather than source, it names every transitive dependency, and it is
662
+ * committed by convention, which makes it the better of the two to read.
663
+ *
664
+ * `identity` rather than `location`, because that is the name SPM itself uses and
665
+ * the one that survives a repository moving host.
666
+ */
667
+ for (const file of ownManifests.filter((f) => /(^|\/)Package\.resolved$/.test(f))) {
668
+ const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
669
+ for (const match of raw.matchAll(/"identity"\s*:\s*"([^"]+)"/g)) {
670
+ swiftDeps.push(match[1].toLowerCase());
671
+ }
672
+ }
654
673
  /**
655
674
  * .csproj, which is XML rather than JSON or one-entry-per-line.
656
675
  *
@@ -29,9 +29,74 @@ const REPORTER_DEPS = [
29
29
  'trackjs',
30
30
  ];
31
31
  const REPORTER_PY_DEPS = ['sentry-sdk', 'rollbar', 'bugsnag'];
32
+ /**
33
+ * The same capability, in the ecosystems where the browser does not exist.
34
+ *
35
+ * This detector was written for code running in a tab and then made `required` for the
36
+ * mobile-app profile, where none of what it reads can occur: an iOS application has no
37
+ * `window.onerror` and no npm dependency. Three real repositories measured — DuckDuckGo
38
+ * iOS, thunderbird-android, WordPress-iOS — and all three were told at `high` that a
39
+ * crash on someone's phone reaches nobody. A check whose answer cannot vary is not a
40
+ * measure.
41
+ *
42
+ * Coordinates rather than words, matched as substrings by the helpers: `io.sentry` also
43
+ * covers `io.sentry:sentry-android`, and `sentry-cocoa` is how SPM names the package
44
+ * whatever the product is called.
45
+ */
46
+ const REPORTER_GRADLE_DEPS = [
47
+ 'io.sentry',
48
+ 'com.google.firebase:firebase-crashlytics',
49
+ 'com.bugsnag',
50
+ 'com.microsoft.appcenter:appcenter-crashes',
51
+ 'ch.acra:acra',
52
+ 'io.embrace',
53
+ 'com.datadoghq:dd-sdk-android',
54
+ 'com.instabug',
55
+ ];
56
+ const REPORTER_SWIFT_DEPS = [
57
+ 'sentry-cocoa',
58
+ 'sentry-cocoa-spm',
59
+ 'firebase-ios-sdk',
60
+ 'firebasecrashlytics',
61
+ 'bugsnag-cocoa',
62
+ 'appcenter-sdk-apple',
63
+ 'instabug',
64
+ 'embrace-apple-sdk',
65
+ ];
66
+ const REPORTER_DART_DEPS = ['sentry_flutter', 'firebase_crashlytics', 'bugsnag_flutter'];
67
+ /**
68
+ * Handlers the platform defines, which no author names.
69
+ *
70
+ * `NSSetUncaughtExceptionHandler` is Foundation, `Thread.setDefaultUncaughtExceptionHandler`
71
+ * is the JVM, `FlutterError.onError` is Flutter. Each is the one place its platform
72
+ * lets a program learn that it is about to die, so a call to it is the capability
73
+ * itself rather than a word that tends to accompany it — thunderbird-android installs
74
+ * one and was reported as having nothing.
75
+ *
76
+ * `SentrySDK.start` and `FirebaseCrashlytics` are here as well as in the dependency
77
+ * lists: a repository can use a reporter whose manifest is in another repository, and
78
+ * the call is still a call.
79
+ */
80
+ const PLATFORM_CRASH_HANDLERS = [
81
+ /NSSetUncaughtExceptionHandler\s*\(/,
82
+ // `(` or `{`: Kotlin writes the handler as a trailing lambda, which is how both real
83
+ // Android applications measured install theirs.
84
+ /setDefaultUncaughtExceptionHandler\s*[({]/,
85
+ /FlutterError\s*\.\s*onError\s*=/,
86
+ /PlatformDispatcher\s*\.\s*instance\s*\.\s*onError\s*=/,
87
+ /SentrySDK\s*\.\s*start\s*\(/,
88
+ /Sentry\s*\.\s*init\s*\(/,
89
+ /FirebaseCrashlytics|Crashlytics\s*\.\s*crashlytics\s*\(/,
90
+ ];
32
91
  async function detectErrorReporting(ctx) {
33
92
  const evidence = [];
34
- const deps = [...(0, detectContext_1.hasAnyDep)(ctx, REPORTER_DEPS), ...(0, detectContext_1.hasAnyPyDep)(ctx, REPORTER_PY_DEPS)];
93
+ const deps = [
94
+ ...(0, detectContext_1.hasAnyDep)(ctx, REPORTER_DEPS),
95
+ ...(0, detectContext_1.hasAnyPyDep)(ctx, REPORTER_PY_DEPS),
96
+ ...(0, detectContext_1.hasAnyGradleDep)(ctx, REPORTER_GRADLE_DEPS),
97
+ ...(0, detectContext_1.hasAnySwiftDep)(ctx, REPORTER_SWIFT_DEPS),
98
+ ...(0, detectContext_1.hasAnyDartDep)(ctx, REPORTER_DART_DEPS),
99
+ ];
35
100
  for (const dep of deps)
36
101
  evidence.push({ type: 'dependency', value: dep });
37
102
  /**
@@ -44,6 +109,7 @@ async function detectErrorReporting(ctx) {
44
109
  /window\.addEventListener\s*\(\s*['"]unhandledrejection['"]/,
45
110
  /window\.onerror\s*=/,
46
111
  /componentDidCatch\s*\([^)]*\)\s*\{[^}]*(fetch|report|log)/s,
112
+ ...PLATFORM_CRASH_HANDLERS,
47
113
  ], 10);
48
114
  for (const hit of handlers) {
49
115
  evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
@@ -54,7 +120,7 @@ async function detectErrorReporting(ctx) {
54
120
  // A reporting service is wired up once and covers everything; a hand-rolled handler
55
121
  // usually covers what its author remembered.
56
122
  complete: deps.length > 0,
57
- evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'somewhere a crash in the browser is sent', ['@sentry/browser', '@sentry/react', '@bugsnag/js', 'rollbar', 'logrocket', '@datadog/browser-rum', 'sentry-sdk', 'window.onerror', 'addEventListener("error")', 'addEventListener("unhandledrejection")']),
123
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'somewhere a crash in the browser is sent', ['@sentry/browser', '@sentry/react', '@bugsnag/js', 'rollbar', 'logrocket', '@datadog/browser-rum', 'sentry-sdk', 'window.onerror', 'addEventListener("error")', 'addEventListener("unhandledrejection")', 'NSSetUncaughtExceptionHandler', 'Thread.setDefaultUncaughtExceptionHandler', 'FlutterError.onError', 'sentry-cocoa', 'io.sentry', 'firebase-crashlytics']),
58
124
  details: { services: deps, handlers: handlers.length },
59
125
  };
60
126
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectGame = detectGame;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
+ const localStores_1 = require("./localStores");
6
7
  const absenceEvidence_1 = require("./absenceEvidence");
7
8
  /**
8
9
  * Whether this project is a game.
@@ -151,20 +152,42 @@ async function detectStatePersistence(ctx) {
151
152
  }
152
153
  // A database is the durable half. Read from the stack rather than re-detected, so
153
154
  // this agrees with what the report says the data layer is.
154
- const durable = ctx.files.all.some((file) => /(^|\/)(schema\.prisma|.*\.sql)$/i.test(file))
155
+ const serverStore = ctx.files.all.some((file) => /(^|\/)(schema\.prisma|.*\.sql)$/i.test(file))
155
156
  || Boolean(ctx.packageJson?.dependencies?.pg)
156
157
  || Boolean(ctx.packageJson?.dependencies?.mongoose)
157
158
  || Boolean(ctx.packageJson?.dependencies?.['better-sqlite3']);
158
- if (durable)
159
+ /**
160
+ * On a device, the local store is the durable one.
161
+ *
162
+ * This whole check was written for a game in a browser, where `localStorage` is the
163
+ * partial case because a cleared cache takes the save with it. That reasoning does
164
+ * not carry to a phone: Core Data and SQLite survive the application being killed,
165
+ * the device restarting and the person coming back a week later — they are the
166
+ * durable half there, not the fragile one.
167
+ *
168
+ * It is required of the mobile-app profile, and all four real applications measured
169
+ * came back `partial` on it while keeping everything they own on disk. The failure
170
+ * this should catch on a phone is different and still caught: state held only in
171
+ * memory, written nowhere, gone when the operating system reclaims the process.
172
+ */
173
+ const deviceStore = await (0, localStores_1.readLocalStores)(ctx, 3);
174
+ const onDevice = deviceStore.dependencies.length > 0 || deviceStore.uses.length > 0;
175
+ const durable = serverStore || onDevice;
176
+ if (serverStore)
159
177
  evidence.push({ type: 'note', value: 'a durable store is present' });
178
+ for (const dep of deviceStore.dependencies)
179
+ evidence.push({ type: 'dependency', value: dep });
180
+ for (const use of deviceStore.uses) {
181
+ evidence.push({ type: 'snippet', value: use.snippet, file: use.file, line: use.line });
182
+ }
160
183
  const saves = clientOnly.length > 0 || saveRoutes.length > 0;
161
184
  return {
162
185
  key: 'app.stateDurability',
163
186
  present: saves || durable,
164
187
  // Client storage alone is the partial case: it saves, until it does not.
165
188
  complete: durable,
166
- evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anywhere progress is written down', ['localStorage', 'sessionStorage', 'IndexedDB', 'a save route', 'schema.prisma', 'a .sql file', 'pg', 'mongoose', 'better-sqlite3']),
167
- details: { durable, clientStorage: clientOnly.length > 0, saveRoutines: saveRoutes.length },
189
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anywhere progress is written down', ['localStorage', 'sessionStorage', 'IndexedDB', 'a save route', 'schema.prisma', 'a .sql file', 'pg', 'mongoose', 'better-sqlite3', 'Core Data', 'SQLiteOpenHelper', 'Room', 'sqflite']),
190
+ details: { durable, serverStore, onDevice, clientStorage: clientOnly.length > 0, saveRoutines: saveRoutes.length },
168
191
  };
169
192
  }
170
193
  /**
@@ -4,6 +4,7 @@ exports.detectMobile = detectMobile;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const readTextFileSafe_1 = require("../utils/readTextFileSafe");
6
6
  const textSearch_1 = require("../utils/textSearch");
7
+ const localStores_1 = require("./localStores");
7
8
  const absenceEvidence_1 = require("./absenceEvidence");
8
9
  /**
9
10
  * Whether this is an application that ships to somebody else's phone, and what that
@@ -71,17 +72,6 @@ const SECURE_STORAGE_SWIFT = [
71
72
  'square/valet',
72
73
  'valet',
73
74
  ];
74
- /** Local databases, by platform, that let an app open without a network. */
75
- const OFFLINE_GRADLE = ['androidx.room', 'io.realm', 'io.objectbox', 'com.squareup.sqldelight', 'app.cash.sqldelight'];
76
- const OFFLINE_SWIFT = [
77
- 'groue/grdb.swift',
78
- 'grdb.swift',
79
- 'stephencelis/sqlite.swift',
80
- 'sqlite.swift',
81
- 'realm/realm-swift',
82
- 'realm/realm-cocoa',
83
- 'realmswift',
84
- ];
85
75
  /** Dependencies that put a secret somewhere the operating system protects. */
86
76
  const SECURE_STORAGE_DEPS = [
87
77
  'flutter_secure_storage',
@@ -91,22 +81,6 @@ const SECURE_STORAGE_DEPS = [
91
81
  '@capacitor/preferences',
92
82
  'capacitor-secure-storage-plugin',
93
83
  ];
94
- /** Dependencies whose whole purpose is that the app works with no network. */
95
- const OFFLINE_DEPS = [
96
- 'sqflite',
97
- 'drift',
98
- 'hive',
99
- 'isar',
100
- 'objectbox',
101
- 'realm',
102
- 'watermelondb',
103
- '@nozbe/watermelondb',
104
- 'react-native-mmkv',
105
- '@react-native-async-storage/async-storage',
106
- 'redux-persist',
107
- '@tanstack/query-persist-client-core',
108
- 'powersync',
109
- ];
110
84
  /**
111
85
  * Paths that contain a platform marker without being a project.
112
86
  *
@@ -303,20 +277,23 @@ async function detectMobile(ctx) {
303
277
  line: match.line,
304
278
  })),
305
279
  ];
306
- const offlineDeps = [
307
- ...(0, detectContext_1.hasAnyDep)(ctx, OFFLINE_DEPS),
308
- ...(0, detectContext_1.hasAnyDartDep)(ctx, OFFLINE_DEPS),
309
- ...(0, detectContext_1.hasAnyGradleDep)(ctx, OFFLINE_GRADLE),
310
- ...(0, detectContext_1.hasAnySwiftDep)(ctx, OFFLINE_SWIFT),
311
- ];
280
+ const localStores = await (0, localStores_1.readLocalStores)(ctx);
281
+ const offlineDeps = localStores.dependencies;
312
282
  /**
313
283
  * A local database is the strong signal; knowing the network dropped is the weak
314
284
  * one. Both are recorded, because an app that stores nothing but tells the user the
315
285
  * connection is gone is a different thing from one that shows a spinner forever.
316
286
  */
317
287
  const connectivityChecks = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/Connectivity\(\)/, /connectivity_plus/, /NetInfo\./, /navigator\.onLine/, /NWPathMonitor/, /isReachable/], 3);
288
+ const platformStores = localStores.uses;
318
289
  const offlineEvidence = [
319
290
  ...offlineDeps.map((dep) => ({ type: 'dependency', value: dep })),
291
+ ...platformStores.map((match) => ({
292
+ type: 'snippet',
293
+ value: match.snippet,
294
+ file: match.file,
295
+ line: match.line,
296
+ })),
320
297
  ...connectivityChecks.map((match) => ({
321
298
  type: 'snippet',
322
299
  value: match.snippet,
@@ -361,8 +338,8 @@ async function detectMobile(ctx) {
361
338
  },
362
339
  {
363
340
  key: 'mobile.offline',
364
- present: offlineDeps.length > 0,
365
- evidence: (0, absenceEvidence_1.evidenceOrSearch)(offlineEvidence, 'a local database the app can read with no network', ['sqflite', 'drift', 'hive', 'isar', 'objectbox', 'realm', 'androidx.room', 'sqldelight', 'grdb.swift', 'sqlite.swift']),
341
+ present: offlineDeps.length > 0 || platformStores.length > 0,
342
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(offlineEvidence, 'a local database the app can read with no network', ['sqflite', 'drift', 'hive', 'isar', 'objectbox', 'realm', 'androidx.room', 'sqldelight', 'grdb.swift', 'sqlite.swift', 'NSManagedObjectContext', 'SQLiteOpenHelper', 'getWritableDatabase(']),
366
343
  },
367
344
  {
368
345
  key: 'mobile.forcedUpdate',
@@ -0,0 +1,40 @@
1
+ import type { DetectContext } from './detectContext';
2
+ /**
3
+ * Where an application on somebody else's device writes things down.
4
+ *
5
+ * One list, read by the two capabilities that both ask this question and used to
6
+ * answer it differently: `mobile.offline` — can this open with no network — and
7
+ * `app.state-durability` — does the person's work survive. A second copy would drift,
8
+ * and it already had: durability was written for a game in a browser and knew only
9
+ * `localStorage` and four npm packages, so every native application measured came back
10
+ * `partial` while keeping its data in Core Data or SQLite.
11
+ */
12
+ /** Local databases declared as dependencies, by ecosystem. */
13
+ export declare const LOCAL_STORE_GRADLE: string[];
14
+ export declare const LOCAL_STORE_SWIFT: string[];
15
+ export declare const LOCAL_STORE_DEPS: string[];
16
+ /**
17
+ * Storage the platform itself provides, which no dependency list will hold.
18
+ *
19
+ * Every entry above is a third-party database, and the two most widely used stores on
20
+ * these platforms ship with the operating system: Core Data on Apple's,
21
+ * `SQLiteDatabase` on Android's. Measured on two applications whose whole purpose is
22
+ * working without a network — WordPress-iOS keeps its posts in Core Data,
23
+ * thunderbird-android keeps its mail in SQLite — and both were told they store nothing
24
+ * locally.
25
+ *
26
+ * Types the platform defines, not names an author picked: `NSManagedObjectContext`
27
+ * belongs to Core Data and appears nowhere else, and `getWritableDatabase()` is the one
28
+ * way an Android application opens its own database.
29
+ */
30
+ export declare const PLATFORM_LOCAL_STORES: RegExp[];
31
+ export interface LocalStoreReading {
32
+ dependencies: string[];
33
+ uses: Array<{
34
+ file: string;
35
+ line: number;
36
+ snippet: string;
37
+ }>;
38
+ }
39
+ /** Both halves of the question, so a caller can cite whichever it found. */
40
+ export declare function readLocalStores(ctx: DetectContext, limit?: number): Promise<LocalStoreReading>;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLATFORM_LOCAL_STORES = exports.LOCAL_STORE_DEPS = exports.LOCAL_STORE_SWIFT = exports.LOCAL_STORE_GRADLE = void 0;
4
+ exports.readLocalStores = readLocalStores;
5
+ const detectContext_1 = require("./detectContext");
6
+ const textSearch_1 = require("../utils/textSearch");
7
+ /**
8
+ * Where an application on somebody else's device writes things down.
9
+ *
10
+ * One list, read by the two capabilities that both ask this question and used to
11
+ * answer it differently: `mobile.offline` — can this open with no network — and
12
+ * `app.state-durability` — does the person's work survive. A second copy would drift,
13
+ * and it already had: durability was written for a game in a browser and knew only
14
+ * `localStorage` and four npm packages, so every native application measured came back
15
+ * `partial` while keeping its data in Core Data or SQLite.
16
+ */
17
+ /** Local databases declared as dependencies, by ecosystem. */
18
+ exports.LOCAL_STORE_GRADLE = ['androidx.room', 'io.realm', 'io.objectbox', 'com.squareup.sqldelight', 'app.cash.sqldelight', 'androidx.datastore'];
19
+ exports.LOCAL_STORE_SWIFT = [
20
+ 'groue/grdb.swift',
21
+ 'grdb.swift',
22
+ 'stephencelis/sqlite.swift',
23
+ 'sqlite.swift',
24
+ 'realm/realm-swift',
25
+ 'realm/realm-cocoa',
26
+ 'realmswift',
27
+ ];
28
+ exports.LOCAL_STORE_DEPS = [
29
+ 'sqflite',
30
+ 'drift',
31
+ 'hive',
32
+ 'isar',
33
+ 'objectbox',
34
+ 'realm',
35
+ 'watermelondb',
36
+ '@nozbe/watermelondb',
37
+ 'react-native-mmkv',
38
+ '@react-native-async-storage/async-storage',
39
+ 'redux-persist',
40
+ '@tanstack/query-persist-client-core',
41
+ 'powersync',
42
+ ];
43
+ /**
44
+ * Storage the platform itself provides, which no dependency list will hold.
45
+ *
46
+ * Every entry above is a third-party database, and the two most widely used stores on
47
+ * these platforms ship with the operating system: Core Data on Apple's,
48
+ * `SQLiteDatabase` on Android's. Measured on two applications whose whole purpose is
49
+ * working without a network — WordPress-iOS keeps its posts in Core Data,
50
+ * thunderbird-android keeps its mail in SQLite — and both were told they store nothing
51
+ * locally.
52
+ *
53
+ * Types the platform defines, not names an author picked: `NSManagedObjectContext`
54
+ * belongs to Core Data and appears nowhere else, and `getWritableDatabase()` is the one
55
+ * way an Android application opens its own database.
56
+ */
57
+ exports.PLATFORM_LOCAL_STORES = [
58
+ /NSPersistentContainer|NSManagedObjectContext|NSPersistentStoreCoordinator/,
59
+ /ModelContainer\s*\(|@Model\b/,
60
+ /SQLiteOpenHelper|getWritableDatabase\s*\(|getReadableDatabase\s*\(/,
61
+ /android\.database\.sqlite\.SQLiteDatabase/,
62
+ ];
63
+ /** Both halves of the question, so a caller can cite whichever it found. */
64
+ async function readLocalStores(ctx, limit = 3) {
65
+ return {
66
+ dependencies: [
67
+ ...(0, detectContext_1.hasAnyDep)(ctx, exports.LOCAL_STORE_DEPS),
68
+ ...(0, detectContext_1.hasAnyDartDep)(ctx, exports.LOCAL_STORE_DEPS),
69
+ ...(0, detectContext_1.hasAnyGradleDep)(ctx, exports.LOCAL_STORE_GRADLE),
70
+ ...(0, detectContext_1.hasAnySwiftDep)(ctx, exports.LOCAL_STORE_SWIFT),
71
+ ],
72
+ uses: await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, exports.PLATFORM_LOCAL_STORES, limit),
73
+ };
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.74.0",
3
+ "version": "0.76.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": {