@produtype/core 0.3.1 → 0.3.3
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 +15 -1
- package/dist/analyzer/analyzeProject.js +4 -1
- package/dist/analyzer/detectMobile.d.ts +3 -0
- package/dist/analyzer/detectMobile.js +237 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +4 -1
- package/dist/cli.js +1 -1
- package/dist/expectations/productProfiles.d.ts +26 -0
- package/dist/expectations/productProfiles.js +112 -3
- package/dist/expectations/profileSignals.d.ts +2 -0
- package/dist/expectations/profileSignals.js +28 -0
- package/dist/expectations/types.d.ts +2 -2
- package/dist/report/executiveSummary.js +1 -0
- package/dist/report/types.d.ts +1 -1
- package/dist/report/types.js +1 -0
- package/package.json +1 -1
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
|
|
@@ -196,6 +196,20 @@ its own.
|
|
|
196
196
|
- Plan output is deterministic and read-only only
|
|
197
197
|
- No cloud dashboard or UI: this package is the CLI and the library
|
|
198
198
|
|
|
199
|
+
## Verifying what you installed
|
|
200
|
+
|
|
201
|
+
Every release from 0.3.3 onwards is published with npm provenance: an attestation,
|
|
202
|
+
signed during the release workflow, that ties the tarball to the commit and the build
|
|
203
|
+
that produced it.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
npm view @produtype/core --json | grep -A5 provenance
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Releases 0.3.0 to 0.3.2 have no attestation. They were published while this repository
|
|
210
|
+
was private, and provenance is only meaningful when anyone can read the commit it
|
|
211
|
+
points at.
|
|
212
|
+
|
|
199
213
|
## Roadmap
|
|
200
214
|
|
|
201
215
|
Shipped: the deterministic analyzer, deterministic remediation planning, and the
|
|
@@ -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,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
|
-
|
|
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: '
|
|
207
|
-
recommendation: 'Persist progress
|
|
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
|
};
|
package/dist/report/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/report/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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": {
|