@produtype/core 0.3.1 → 0.3.2

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
@@ -99,7 +99,7 @@ Options:
99
99
  - `--format markdown|json` output format for the report or plan payload
100
100
  - `--summary` print summary only
101
101
  - `--output <path>` write output to file
102
- - `--profile <name>` evaluate expected product capabilities (`static-site`, `internal-tool`, `b2c-app`, `b2b-saas`, `ai-saas`, `marketplace`, `game`, `client-app`, `auto`, `observed-only`)
102
+ - `--profile <name>` evaluate expected product capabilities (`static-site`, `internal-tool`, `b2c-app`, `b2b-saas`, `ai-saas`, `marketplace`, `game`, `client-app`, `mobile-app`, `auto`, `observed-only`)
103
103
  - `--fail-under <score>` (analyze only) exit with code 1 if the overall score is below the threshold — useful as a CI quality gate
104
104
  - `--min-maturity <level>` (analyze only) exit with code 1 if maturity is below `prototype|early|partial|production_ready`
105
105
  - `--ai` (analyze only) add an AI stack/architecture insight — opt-in, advisory only, does not affect the score
@@ -45,6 +45,7 @@ const detectDatabase_1 = require("./detectDatabase");
45
45
  const detectAudit_1 = require("./detectAudit");
46
46
  const detectGame_1 = require("./detectGame");
47
47
  const detectClientLogic_1 = require("./detectClientLogic");
48
+ const detectMobile_1 = require("./detectMobile");
48
49
  const detectErrorReporting_1 = require("./detectErrorReporting");
49
50
  const detectDocker_1 = require("./detectDocker");
50
51
  const detectEnv_1 = require("./detectEnv");
@@ -460,7 +461,7 @@ async function analyzeProject(projectPath) {
460
461
  npmDeps,
461
462
  workspaces,
462
463
  };
463
- const [pm, frontend, backend, database, docker, env, auth, security, uploads, gdpr, billing, observability, jobs, marketplace, aiSafety, engagement, deployment, audit, game, clientLogic, errorReporting] = await Promise.all([
464
+ const [pm, frontend, backend, database, docker, env, auth, security, uploads, gdpr, billing, observability, jobs, marketplace, aiSafety, engagement, deployment, audit, game, clientLogic, mobile, errorReporting] = await Promise.all([
464
465
  (0, detectPackageManager_1.detectPackageManager)(ctx),
465
466
  (0, detectFrontend_1.detectFrontend)(ctx),
466
467
  (0, detectBackend_1.detectBackend)(ctx),
@@ -481,6 +482,7 @@ async function analyzeProject(projectPath) {
481
482
  (0, detectAudit_1.detectAudit)(ctx),
482
483
  (0, detectGame_1.detectGame)(ctx),
483
484
  (0, detectClientLogic_1.detectClientLogic)(ctx),
485
+ (0, detectMobile_1.detectMobile)(ctx),
484
486
  (0, detectErrorReporting_1.detectErrorReporting)(ctx),
485
487
  ]);
486
488
  const detectors = (0, detectStack_1.mergeDetectors)([
@@ -492,6 +494,7 @@ async function analyzeProject(projectPath) {
492
494
  audit,
493
495
  ...game,
494
496
  clientLogic,
497
+ ...mobile,
495
498
  errorReporting,
496
499
  docker,
497
500
  ...env,
@@ -0,0 +1,3 @@
1
+ import type { DetectorResult } from './types';
2
+ import { type DetectContext } from './detectContext';
3
+ export declare function detectMobile(ctx: DetectContext): Promise<DetectorResult[]>;
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectMobile = detectMobile;
4
+ const detectContext_1 = require("./detectContext");
5
+ const readTextFileSafe_1 = require("../utils/readTextFileSafe");
6
+ const textSearch_1 = require("../utils/textSearch");
7
+ /**
8
+ * Whether this is an application that ships to somebody else's phone, and what that
9
+ * demands of it.
10
+ *
11
+ * A mobile app lands today in `client-app`, which is the closest profile and
12
+ * underestimates it: that profile asks that the user's work survives, that the bundle
13
+ * arrives and that crashes are heard about, and asks nothing about the fact that the
14
+ * code runs on a device its author does not own, behind a review process, on a network
15
+ * that comes and goes, in a version that may still be installed in a year.
16
+ *
17
+ * Five expectations, none of which a web application has:
18
+ *
19
+ * **Permissions asked in context.** A camera prompt on first launch is rejected by the
20
+ * stores before it is rejected by users. The signal is not the permission — every app
21
+ * declares some — it is whether each declared permission has a purpose string next to
22
+ * it, which is the thing a reviewer reads.
23
+ *
24
+ * **Credentials in the keychain.** On a phone the file system is not a security
25
+ * boundary: anything in preferences or a plain file is readable on a rooted device and
26
+ * often in a backup. Tokens belong in Keychain or Keystore.
27
+ *
28
+ * **Working offline.** A network that comes and goes is the normal state, not an error
29
+ * case. An app that renders a spinner in a lift is broken in a way its author never
30
+ * sees on a desk.
31
+ *
32
+ * **Forced updates.** The old client stays installed for months whatever the release
33
+ * notes say, so the API either keeps supporting it or can tell it to stop.
34
+ *
35
+ * **A privacy declaration.** The App Store and Play listings are a public, checkable
36
+ * commitment, and it is common for them not to match what the code does.
37
+ */
38
+ /** Files that say which platform this is, without reading their contents. */
39
+ const IOS_MARKERS = [/(^|\/)Info\.plist$/i, /(^|\/)Podfile$/, /\.xcodeproj\//, /(^|\/)Package\.swift$/];
40
+ const ANDROID_MARKERS = [/(^|\/)AndroidManifest\.xml$/i, /(^|\/)build\.gradle(\.kts)?$/];
41
+ /** Dependencies that put a secret somewhere the operating system protects. */
42
+ const SECURE_STORAGE_DEPS = [
43
+ 'flutter_secure_storage',
44
+ 'react-native-keychain',
45
+ 'expo-secure-store',
46
+ 'react-native-encrypted-storage',
47
+ '@capacitor/preferences',
48
+ 'capacitor-secure-storage-plugin',
49
+ ];
50
+ /** Dependencies whose whole purpose is that the app works with no network. */
51
+ const OFFLINE_DEPS = [
52
+ 'sqflite',
53
+ 'drift',
54
+ 'hive',
55
+ 'isar',
56
+ 'objectbox',
57
+ 'realm',
58
+ 'watermelondb',
59
+ '@nozbe/watermelondb',
60
+ 'react-native-mmkv',
61
+ '@react-native-async-storage/async-storage',
62
+ 'redux-persist',
63
+ '@tanstack/query-persist-client-core',
64
+ 'powersync',
65
+ ];
66
+ function matchesAny(files, patterns) {
67
+ return files.filter((file) => patterns.some((pattern) => pattern.test(file)));
68
+ }
69
+ async function detectMobile(ctx) {
70
+ const iosFiles = matchesAny(ctx.files.all, IOS_MARKERS);
71
+ const androidFiles = matchesAny(ctx.files.all, ANDROID_MARKERS);
72
+ const flutterDeps = (0, detectContext_1.hasAnyDartDep)(ctx, ['flutter']);
73
+ const reactNativeDeps = (0, detectContext_1.hasAnyDep)(ctx, ['react-native', 'expo']);
74
+ const platforms = [];
75
+ const platformEvidence = [];
76
+ if (flutterDeps.length) {
77
+ platforms.push('flutter');
78
+ platformEvidence.push({ type: 'dependency', value: 'flutter' });
79
+ }
80
+ if (reactNativeDeps.length) {
81
+ platforms.push('react-native');
82
+ for (const dep of reactNativeDeps)
83
+ platformEvidence.push({ type: 'dependency', value: dep });
84
+ }
85
+ if (iosFiles.length) {
86
+ platforms.push('ios');
87
+ platformEvidence.push({ type: 'file', value: iosFiles[0], file: iosFiles[0] });
88
+ }
89
+ if (androidFiles.length) {
90
+ platforms.push('android');
91
+ platformEvidence.push({ type: 'file', value: androidFiles[0], file: androidFiles[0] });
92
+ }
93
+ const isMobile = platforms.length > 0;
94
+ /**
95
+ * Every capability below reports `present: false` when this is not a mobile project,
96
+ * with no evidence — the profile that asks about them is the only one that applies
97
+ * them, and a web repository being told it has no Keychain usage would be noise.
98
+ */
99
+ if (!isMobile) {
100
+ return [
101
+ { key: 'mobile.platform', present: false, evidence: [] },
102
+ { key: 'mobile.permissions', present: false, evidence: [] },
103
+ { key: 'mobile.credentialStorage', present: false, evidence: [] },
104
+ { key: 'mobile.offline', present: false, evidence: [] },
105
+ { key: 'mobile.forcedUpdate', present: false, evidence: [] },
106
+ { key: 'mobile.privacyDeclaration', present: false, evidence: [] },
107
+ ];
108
+ }
109
+ // Permissions: declared is not the question, explained is.
110
+ const permissionEvidence = [];
111
+ let permissionsExplained = false;
112
+ for (const file of iosFiles.filter((f) => /Info\.plist$/i.test(f))) {
113
+ const text = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file)) ?? '';
114
+ // Apple's convention: every permission key has a matching UsageDescription whose
115
+ // value is shown to the person being asked. An empty one passes the compiler and
116
+ // fails review.
117
+ const usageKeys = [...text.matchAll(/<key>(NS\w*UsageDescription)<\/key>\s*<string>([^<]*)<\/string>/g)];
118
+ const explained = usageKeys.filter(([, , reason]) => reason.trim().length > 0);
119
+ if (explained.length) {
120
+ permissionsExplained = true;
121
+ permissionEvidence.push({
122
+ type: 'file',
123
+ value: `${explained.length} permission${explained.length === 1 ? '' : 's'} with a reason in ${file}`,
124
+ file,
125
+ });
126
+ }
127
+ else if (usageKeys.length) {
128
+ permissionEvidence.push({
129
+ type: 'file',
130
+ value: `permissions declared with an empty reason in ${file}`,
131
+ file,
132
+ });
133
+ }
134
+ }
135
+ for (const file of androidFiles.filter((f) => /AndroidManifest\.xml$/i.test(f))) {
136
+ const text = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file)) ?? '';
137
+ const declared = [...text.matchAll(/<uses-permission[^>]*android:name="([^"]+)"/g)].map(([, name]) => name);
138
+ // Android has no purpose strings, so the equivalent evidence is asking at the
139
+ // moment of use: a runtime request in the code rather than only a manifest entry.
140
+ if (declared.length) {
141
+ permissionEvidence.push({
142
+ type: 'file',
143
+ value: `${declared.length} permission${declared.length === 1 ? '' : 's'} declared in ${file}`,
144
+ file,
145
+ });
146
+ }
147
+ }
148
+ const runtimeRequests = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
149
+ /requestPermissions?\(/,
150
+ /ActivityCompat\.requestPermissions/,
151
+ /shouldShowRequestPermissionRationale/,
152
+ /Permission\.\w+\.request\(/,
153
+ /request\(\)\s*;?\s*\/\/\s*permission/,
154
+ /PermissionsAndroid\.request/,
155
+ /requestPermissionsAsync/,
156
+ ], 3);
157
+ for (const match of runtimeRequests) {
158
+ permissionsExplained = true;
159
+ permissionEvidence.push({ type: 'snippet', value: match.snippet, file: match.file, line: match.line });
160
+ }
161
+ const secureStorage = (0, detectContext_1.hasAnyDep)(ctx, SECURE_STORAGE_DEPS);
162
+ const secureStorageDart = (0, detectContext_1.hasAnyDartDep)(ctx, ['flutter_secure_storage']);
163
+ const keychainInSource = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/KeychainAccess/, /kSecClass/, /EncryptedSharedPreferences/, /AndroidKeyStore/, /SecItemAdd/], 3);
164
+ const credentialEvidence = [
165
+ ...[...secureStorage, ...secureStorageDart].map((dep) => ({ type: 'dependency', value: dep })),
166
+ ...keychainInSource.map((match) => ({
167
+ type: 'snippet',
168
+ value: match.snippet,
169
+ file: match.file,
170
+ line: match.line,
171
+ })),
172
+ ];
173
+ const offlineDeps = [...(0, detectContext_1.hasAnyDep)(ctx, OFFLINE_DEPS), ...(0, detectContext_1.hasAnyDartDep)(ctx, OFFLINE_DEPS)];
174
+ /**
175
+ * A local database is the strong signal; knowing the network dropped is the weak
176
+ * one. Both are recorded, because an app that stores nothing but tells the user the
177
+ * connection is gone is a different thing from one that shows a spinner forever.
178
+ */
179
+ const connectivityChecks = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/Connectivity\(\)/, /connectivity_plus/, /NetInfo\./, /navigator\.onLine/, /NWPathMonitor/, /isReachable/], 3);
180
+ const offlineEvidence = [
181
+ ...offlineDeps.map((dep) => ({ type: 'dependency', value: dep })),
182
+ ...connectivityChecks.map((match) => ({
183
+ type: 'snippet',
184
+ value: match.snippet,
185
+ file: match.file,
186
+ line: match.line,
187
+ })),
188
+ ];
189
+ const forcedUpdate = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
190
+ /minimum_?[Vv]ersion/,
191
+ /force_?[Uu]pdate/,
192
+ /forceUpgrade/,
193
+ /upgrader/i,
194
+ /in_app_update/,
195
+ /AppUpdateManager/,
196
+ /minimumSupportedVersion/,
197
+ ], 3);
198
+ /**
199
+ * The privacy declaration, which exists as a file in both worlds now: Apple's
200
+ * PrivacyInfo.xcprivacy since 2024, and the data-safety declaration Play requires.
201
+ * A repository with neither has made the commitment somewhere no reviewer of this
202
+ * code can check it against the code.
203
+ */
204
+ const privacyFiles = ctx.files.all.filter((file) => /(^|\/)(PrivacyInfo\.xcprivacy|privacy-manifest\.json|data_safety\.ya?ml)$/i.test(file));
205
+ return [
206
+ {
207
+ key: 'mobile.platform',
208
+ present: true,
209
+ evidence: platformEvidence,
210
+ details: { platforms },
211
+ },
212
+ {
213
+ key: 'mobile.permissions',
214
+ // Declared-and-explained, or asked at the moment of use. A manifest full of
215
+ // permissions with nothing next to them is the failing case, not the passing one.
216
+ present: permissionsExplained,
217
+ evidence: permissionEvidence,
218
+ },
219
+ { key: 'mobile.credentialStorage', present: credentialEvidence.length > 0, evidence: credentialEvidence },
220
+ { key: 'mobile.offline', present: offlineDeps.length > 0, evidence: offlineEvidence },
221
+ {
222
+ key: 'mobile.forcedUpdate',
223
+ present: forcedUpdate.length > 0,
224
+ evidence: forcedUpdate.map((match) => ({
225
+ type: 'snippet',
226
+ value: match.snippet,
227
+ file: match.file,
228
+ line: match.line,
229
+ })),
230
+ },
231
+ {
232
+ key: 'mobile.privacyDeclaration',
233
+ present: privacyFiles.length > 0,
234
+ evidence: privacyFiles.map((file) => ({ type: 'file', value: file, file })),
235
+ },
236
+ ];
237
+ }
package/dist/api.d.ts CHANGED
@@ -24,6 +24,8 @@ export { PRODKit_VERSION } from './version';
24
24
  * consumer that wants to publish the list should read it from here.
25
25
  */
26
26
  export { supportedStacks, labelFor } from './analyzer/catalogue';
27
+ export { productProfileChoices, getProductProfile } from './expectations/productProfiles';
28
+ export type { ProfileChoice } from './expectations/productProfiles';
27
29
  export type { StackCatalogue, CatalogueEntry } from './analyzer/catalogue';
28
30
  /**
29
31
  * Safe file reading, exported because consumers that sample a repository need the
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.readJsonSafe = exports.readTextFileSafe = exports.labelFor = exports.supportedStacks = exports.PRODKit_VERSION = exports.renderJsonPlan = exports.renderMarkdownPlan = exports.buildPlan = exports.renderJson = exports.renderMarkdown = exports.buildReport = exports.analyzeProject = void 0;
3
+ exports.readJsonSafe = exports.readTextFileSafe = exports.getProductProfile = exports.productProfileChoices = exports.labelFor = exports.supportedStacks = exports.PRODKit_VERSION = exports.renderJsonPlan = exports.renderMarkdownPlan = exports.buildPlan = exports.renderJson = exports.renderMarkdown = exports.buildReport = exports.analyzeProject = void 0;
4
4
  var analyzeProject_1 = require("./analyzer/analyzeProject");
5
5
  Object.defineProperty(exports, "analyzeProject", { enumerable: true, get: function () { return analyzeProject_1.analyzeProject; } });
6
6
  var buildReport_1 = require("./report/buildReport");
@@ -27,6 +27,9 @@ Object.defineProperty(exports, "PRODKit_VERSION", { enumerable: true, get: funct
27
27
  var catalogue_1 = require("./analyzer/catalogue");
28
28
  Object.defineProperty(exports, "supportedStacks", { enumerable: true, get: function () { return catalogue_1.supportedStacks; } });
29
29
  Object.defineProperty(exports, "labelFor", { enumerable: true, get: function () { return catalogue_1.labelFor; } });
30
+ var productProfiles_1 = require("./expectations/productProfiles");
31
+ Object.defineProperty(exports, "productProfileChoices", { enumerable: true, get: function () { return productProfiles_1.productProfileChoices; } });
32
+ Object.defineProperty(exports, "getProductProfile", { enumerable: true, get: function () { return productProfiles_1.getProductProfile; } });
30
33
  /**
31
34
  * Safe file reading, exported because consumers that sample a repository need the
32
35
  * same guarantees the analyzer relies on: binaries and oversized files are skipped,
package/dist/cli.js CHANGED
@@ -46,7 +46,7 @@ const markdownPlan_1 = require("./planner/markdownPlan");
46
46
  const jsonPlan_1 = require("./planner/jsonPlan");
47
47
  const pathUtils_1 = require("./utils/pathUtils");
48
48
  const version_1 = require("./version");
49
- const allowedProfiles = ['observed-only', 'static-site', 'internal-tool', 'b2c-app', 'b2b-saas', 'ai-saas', 'marketplace', 'game', 'client-app', 'auto'];
49
+ const allowedProfiles = ['observed-only', 'static-site', 'internal-tool', 'b2c-app', 'b2b-saas', 'ai-saas', 'marketplace', 'game', 'client-app', 'mobile-app', 'auto'];
50
50
  const maturityOrder = ['prototype', 'early', 'partial', 'production_ready'];
51
51
  function normalizeProfile(profile) {
52
52
  if (!profile)
@@ -42,6 +42,11 @@ declare const CAPABILITIES: {
42
42
  readonly 'app.state-durability': CapabilityBlueprint;
43
43
  readonly 'app.asset-delivery': CapabilityBlueprint;
44
44
  readonly 'client.error-reporting': CapabilityBlueprint;
45
+ readonly 'mobile.permissions': CapabilityBlueprint;
46
+ readonly 'mobile.credential-storage': CapabilityBlueprint;
47
+ readonly 'mobile.offline': CapabilityBlueprint;
48
+ readonly 'mobile.forced-update': CapabilityBlueprint;
49
+ readonly 'mobile.privacy-declaration': CapabilityBlueprint;
45
50
  readonly 'jobs.background': CapabilityBlueprint;
46
51
  readonly 'deployment.readiness': CapabilityBlueprint;
47
52
  readonly 'deployment.docker': CapabilityBlueprint;
@@ -56,5 +61,26 @@ declare const CAPABILITIES: {
56
61
  };
57
62
  export type CapabilityId = keyof typeof CAPABILITIES;
58
63
  export declare const productProfiles: Record<Exclude<ProductProfile, 'auto' | 'observed-only'>, ProductProfileDefinition>;
64
+ /**
65
+ * Every profile a report can be asked for, with the name and sentence a person reads.
66
+ *
67
+ * Exported because consumers were keeping their own copy of this list. The cloud
68
+ * application had its own `ProductProfile` union, its own array for the dropdown and
69
+ * its own label map — and had fallen three profiles behind, so `game`, `client-app` and
70
+ * `mobile-app` existed in the analyzer and could not be chosen in the product built on
71
+ * it. Same failure as the supported-stacks list, one layer up.
72
+ *
73
+ * `auto` and `observed-only` are included because they are choices a caller makes even
74
+ * though they are not profiles with expectations behind them: `auto` asks the analyzer
75
+ * to decide, `observed-only` asks it not to.
76
+ */
77
+ export interface ProfileChoice {
78
+ id: ProductProfile;
79
+ title: string;
80
+ description: string;
81
+ /** Whether expectations exist for it, or it is an instruction about how to judge. */
82
+ judged: boolean;
83
+ }
84
+ export declare function productProfileChoices(): ProfileChoice[];
59
85
  export declare function getProductProfile(profile: Exclude<ProductProfile, 'auto' | 'observed-only'>): ProductProfileDefinition;
60
86
  export {};
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.productProfiles = void 0;
4
+ exports.productProfileChoices = productProfileChoices;
4
5
  exports.getProductProfile = getProductProfile;
5
6
  function blueprint(args) {
6
7
  return args;
@@ -200,11 +201,14 @@ const CAPABILITIES = {
200
201
  }),
201
202
  'app.state-durability': blueprint({
202
203
  id: 'app.state-durability',
203
- title: 'Player progress survives the browser',
204
+ // Worded for every profile that asks it, not only for the one it was written for.
205
+ // A mobile app was being told its "player progress" had to survive "the browser",
206
+ // which is two wrong words in a report meant to be believed.
207
+ title: 'The user\u2019s work survives leaving the app',
204
208
  category: 'client',
205
209
  detectorKeys: ['app.stateDurability'],
206
- description: 'Progress is kept somewhere durable rather than only in browser storage, which is emptied by a cleared cache, a private window or a new device — without the player ever being told the save was not real.',
207
- recommendation: 'Persist progress server-side, and treat browser storage as a cache of it rather than as the record.',
210
+ description: 'What the person has done is kept somewhere durable rather than only in the storage the platform is free to clear — a cleared cache, a private window, a reinstall, a new device — without them ever being told the save was not real.',
211
+ recommendation: 'Persist progress outside volatile storage, and treat the local copy as a cache of the record rather than as the record.',
208
212
  }),
209
213
  'app.asset-delivery': blueprint({
210
214
  id: 'app.asset-delivery',
@@ -222,6 +226,46 @@ const CAPABILITIES = {
222
226
  description: 'On a server a crash is in the logs whether anyone planned for it or not. In code running on someone else\'s device it is not: the screen goes white, the person closes the tab, and nothing records that it happened.',
223
227
  recommendation: 'Report unhandled errors and rejections from the browser to a service you actually read.',
224
228
  }),
229
+ 'mobile.permissions': blueprint({
230
+ id: 'mobile.permissions',
231
+ title: 'Permissions asked in context, with a reason',
232
+ category: 'mobile',
233
+ detectorKeys: ['mobile.permissions'],
234
+ description: 'A permission declared with nothing next to it is a prompt with no explanation. Apple requires a purpose string and reads it in review; Android has none, so the equivalent is asking at the moment of use rather than at launch. An app that asks for the camera on its first screen is refused by the stores before it is refused by users.',
235
+ recommendation: 'Give every declared permission a purpose string, and request it at the point the feature needs it rather than on startup.',
236
+ }),
237
+ 'mobile.credential-storage': blueprint({
238
+ id: 'mobile.credential-storage',
239
+ title: 'Credentials in the keychain',
240
+ category: 'mobile',
241
+ detectorKeys: ['mobile.credentialStorage'],
242
+ description: 'On a phone the file system is not a security boundary. A token in preferences or a plain file is readable on a rooted device and often present in a backup, which is a different exposure from the same mistake on a server nobody else holds.',
243
+ recommendation: 'Keep tokens and keys in Keychain or Keystore, through the platform API or a wrapper over it.',
244
+ }),
245
+ 'mobile.offline': blueprint({
246
+ id: 'mobile.offline',
247
+ title: 'It works when the network does not',
248
+ category: 'mobile',
249
+ detectorKeys: ['mobile.offline'],
250
+ description: 'A network that comes and goes is the normal state of a phone, not an error case. An app with no local store renders a spinner in a lift, on a train and in a basement — none of which its author sees at a desk.',
251
+ recommendation: 'Keep what the person is working on locally, and reconcile with the server when the connection returns.',
252
+ }),
253
+ 'mobile.forced-update': blueprint({
254
+ id: 'mobile.forced-update',
255
+ title: 'The old version can be told to stop',
256
+ category: 'mobile',
257
+ detectorKeys: ['mobile.forcedUpdate'],
258
+ description: 'A web application changes for everyone at once. A client on someone else\u2019s phone stays installed for months whatever the release notes say, so the server either keeps supporting every version it ever shipped or can refuse the ones it no longer does.',
259
+ recommendation: 'Send a minimum supported version from the server and have the client act on it.',
260
+ }),
261
+ 'mobile.privacy-declaration': blueprint({
262
+ id: 'mobile.privacy-declaration',
263
+ title: 'The privacy declaration is in the repository',
264
+ category: 'mobile',
265
+ detectorKeys: ['mobile.privacyDeclaration'],
266
+ description: 'The App Store and Play listings are a public, checkable commitment about what the app collects. Written in a dashboard rather than kept beside the code, it stops matching what the code does, and nobody reviewing the code can tell.',
267
+ recommendation: 'Keep the privacy manifest (PrivacyInfo.xcprivacy, or the data-safety declaration) in the repository, next to what it describes.',
268
+ }),
225
269
  'jobs.background': blueprint({
226
270
  id: 'jobs.background',
227
271
  title: 'Background jobs or queue processing',
@@ -438,6 +482,48 @@ exports.productProfiles = {
438
482
  'deployment.docker': 'recommended',
439
483
  },
440
484
  }),
485
+ /**
486
+ * An application that ships to somebody else's phone.
487
+ *
488
+ * It used to land in `client-app`, which is the nearest profile and underestimates
489
+ * it: that one asks that the user's work survives, that the bundle arrives and that
490
+ * crashes are heard about, and asks nothing about the fact that this code runs on a
491
+ * device its author does not own, behind a review process, on a network that comes
492
+ * and goes, in a version that may still be installed in a year.
493
+ *
494
+ * What it deliberately does not ask for, exactly as client-app does not: tenants,
495
+ * an audit trail, a billing path. A mobile app is a client. The five capabilities
496
+ * that are its own are the ones no web application has.
497
+ */
498
+ 'mobile-app': defineProfile({
499
+ id: 'mobile-app',
500
+ title: 'Mobile application',
501
+ description: 'An app installed on a device its author does not own, shipped through a store.',
502
+ importance: {
503
+ 'mobile.permissions': 'required',
504
+ 'mobile.credential-storage': 'required',
505
+ 'mobile.offline': 'required',
506
+ 'mobile.forced-update': 'recommended',
507
+ 'mobile.privacy-declaration': 'recommended',
508
+ 'app.state-durability': 'required',
509
+ 'client.error-reporting': 'required',
510
+ // The bundle is delivered by the store, not by us — so asset delivery, which
511
+ // client-app requires, is not this profile's problem.
512
+ 'app.asset-delivery': 'optional',
513
+ 'auth.baseline': 'optional',
514
+ 'gdpr.export': 'optional',
515
+ 'gdpr.erasure': 'optional',
516
+ 'security.rate-limit': 'optional',
517
+ 'uploads.protection': 'optional',
518
+ // Neither of these is a mobile application's problem when the repository is only
519
+ // the app: a store ships it rather than a pipeline deploying it, and the logs
520
+ // that matter are on a server that lives somewhere else. Asking for them
521
+ // produces findings nobody can act on, which is what this profile exists to
522
+ // avoid.
523
+ 'observability.logging': 'optional',
524
+ 'deployment.readiness': 'optional',
525
+ },
526
+ }),
441
527
  'b2c-app': defineProfile({
442
528
  id: 'b2c-app',
443
529
  title: 'B2C App',
@@ -578,6 +664,29 @@ exports.productProfiles = {
578
664
  },
579
665
  }),
580
666
  };
667
+ function productProfileChoices() {
668
+ const defined = Object.keys(exports.productProfiles).map((id) => ({
669
+ id: exports.productProfiles[id].id,
670
+ title: exports.productProfiles[id].title,
671
+ description: exports.productProfiles[id].description,
672
+ judged: true,
673
+ }));
674
+ return [
675
+ {
676
+ id: 'auto',
677
+ title: 'Detect automatically',
678
+ description: 'Let the analyzer decide what kind of product this is, and say how sure it is.',
679
+ judged: false,
680
+ },
681
+ ...defined,
682
+ {
683
+ id: 'observed-only',
684
+ title: 'Observed only',
685
+ description: 'Score what the code has, without asking what a product of any kind would need.',
686
+ judged: false,
687
+ },
688
+ ];
689
+ }
581
690
  function getProductProfile(profile) {
582
691
  return exports.productProfiles[profile];
583
692
  }
@@ -40,6 +40,8 @@ export interface ProfileFacts {
40
40
  containerised: boolean;
41
41
  gameEngine: boolean;
42
42
  gameSignals: number;
43
+ /** Ships to a phone: Flutter, React Native, or an iOS/Android project in the tree. */
44
+ mobilePlatforms: string[];
43
45
  sourceFiles: number;
44
46
  }
45
47
  export declare function readFacts(analysis: ProjectAnalysis): ProfileFacts;
@@ -26,6 +26,7 @@ function readFacts(analysis) {
26
26
  containerised: present('deployment.docker'),
27
27
  gameEngine: present('game.engine'),
28
28
  gameSignals: Number(analysis.detectors['game.engine']?.details?.supportingSignals ?? 0),
29
+ mobilePlatforms: analysis.detectors['mobile.platform']?.details?.platforms ?? [],
29
30
  sourceFiles: analysis.files.source.length,
30
31
  };
31
32
  }
@@ -77,6 +78,33 @@ const RULES = [
77
78
  { label: 'accounts to manage', weight: -1, holds: (f) => f.auth },
78
79
  ],
79
80
  },
81
+ {
82
+ /**
83
+ * A mobile application refines client-app for the same reason `game` does: it is a
84
+ * client, and the expectations that separate it are ones no web application has.
85
+ *
86
+ * The platform is what identifies it, and one signal is enough — a repository with
87
+ * an AndroidManifest.xml is an Android application, and no amount of other evidence
88
+ * makes it less one. What the rest of the signals do is separate a real app from a
89
+ * web project that happens to carry a Capacitor shell.
90
+ */
91
+ profile: 'mobile-app',
92
+ refines: 'client-app',
93
+ admissible: (f) => f.mobilePlatforms.length > 0 && !f.tenancy,
94
+ signals: [
95
+ { identifies: true, label: 'a mobile project in the repository', weight: 5, holds: (f) => f.mobilePlatforms.length > 0 },
96
+ {
97
+ identifies: true,
98
+ label: 'built for both iOS and Android',
99
+ weight: 1,
100
+ holds: (f) => f.mobilePlatforms.includes('ios') && f.mobilePlatforms.includes('android'),
101
+ },
102
+ { label: 'enough code to be an application', weight: 1, holds: (f) => f.sourceFiles > 12 },
103
+ // A backend in the same repository does not stop it being a mobile app, but a
104
+ // repository that is mostly a server with a thin client is a server.
105
+ { label: 'a server of its own in the same repository', weight: -1, holds: (f) => f.backend && f.database },
106
+ ],
107
+ },
80
108
  {
81
109
  profile: 'game',
82
110
  refines: 'client-app',
@@ -1,9 +1,9 @@
1
1
  import type { DetectorEvidence } from '../analyzer/types';
2
2
  import type { Finding } from '../report/types';
3
- export type ProductProfile = 'static-site' | 'internal-tool' | 'b2c-app' | 'b2b-saas' | 'ai-saas' | 'game' | 'client-app' | 'marketplace' | 'auto' | 'observed-only';
3
+ export type ProductProfile = 'static-site' | 'internal-tool' | 'b2c-app' | 'b2b-saas' | 'ai-saas' | 'game' | 'client-app' | 'mobile-app' | 'marketplace' | 'auto' | 'observed-only';
4
4
  export type CapabilityImportance = 'required' | 'recommended' | 'optional' | 'not_applicable';
5
5
  export type CapabilityStatus = 'present' | 'missing' | 'partial' | 'unknown' | 'not_applicable';
6
- export type CapabilityCategory = 'auth' | 'authz' | 'tenancy' | 'gdpr' | 'billing' | 'security' | 'uploads' | 'observability' | 'deployment' | 'audit' | 'jobs' | 'client';
6
+ export type CapabilityCategory = 'auth' | 'authz' | 'tenancy' | 'gdpr' | 'billing' | 'security' | 'uploads' | 'observability' | 'deployment' | 'audit' | 'jobs' | 'client' | 'mobile';
7
7
  export interface ExpectedCapability {
8
8
  id: string;
9
9
  title: string;
@@ -21,6 +21,7 @@ const CATEGORY_LABEL = {
21
21
  audit: 'traceability of sensitive actions',
22
22
  observability: 'knowing what happens in production',
23
23
  client: 'saving work and loading the app',
24
+ mobile: 'running on somebody else\u2019s phone',
24
25
  jobs: 'background processing',
25
26
  deployment: 'deploying repeatably',
26
27
  };
@@ -16,7 +16,7 @@ export type EvidenceQuality = 'weak' | 'medium' | 'strong';
16
16
  * because the array had never heard of it. Deriving one from the other makes that
17
17
  * impossible rather than merely unlikely.
18
18
  */
19
- export declare const CATEGORIES: readonly ["meta", "stack", "env", "auth", "authz", "tenancy", "gdpr", "security", "uploads", "billing", "audit", "observability", "client", "jobs", "deployment"];
19
+ export declare const CATEGORIES: readonly ["meta", "stack", "env", "auth", "authz", "tenancy", "gdpr", "security", "uploads", "billing", "audit", "observability", "client", "mobile", "jobs", "deployment"];
20
20
  export type Category = (typeof CATEGORIES)[number];
21
21
  export interface Finding {
22
22
  id: string;
@@ -24,6 +24,7 @@ exports.CATEGORIES = [
24
24
  'audit',
25
25
  'observability',
26
26
  'client',
27
+ 'mobile',
27
28
  'jobs',
28
29
  'deployment',
29
30
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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": {