@produtype/core 0.19.1 → 0.21.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/dist/analyzer/analyzeProject.js +9 -1
- package/dist/analyzer/detectPackageManager.js +33 -1
- package/dist/analyzer/types.d.ts +13 -1
- package/dist/cli.js +23 -3
- package/dist/report/buildReport.js +15 -1
- package/dist/rules/rules.js +62 -13
- package/dist/utils/fileScanner.js +10 -0
- package/package.json +1 -1
|
@@ -652,8 +652,16 @@ async function analyzeProject(projectPath) {
|
|
|
652
652
|
// Said out loud, because a dependency nobody declared is a weaker fact than one
|
|
653
653
|
// that is pinned in a lockfile, and the reader is entitled to know which of the
|
|
654
654
|
// two this reading rests on.
|
|
655
|
+
/**
|
|
656
|
+
* Which manifest was missing, not "a manifest".
|
|
657
|
+
*
|
|
658
|
+
* "No dependency manifest was found: dependencies were read from Python imports"
|
|
659
|
+
* was printed for a Flutter application with a `pubspec.yaml` in its root — the
|
|
660
|
+
* same file the analyzer had just read to identify Flutter. The sentence is about
|
|
661
|
+
* the Python and browser dependency lists specifically, so it says so.
|
|
662
|
+
*/
|
|
655
663
|
...(inferredDependencySources.length > 0
|
|
656
|
-
? [`
|
|
664
|
+
? [`Some dependencies were read from ${inferredDependencySources.join(' and ')} rather than from a manifest, so their versions are unknown.`]
|
|
657
665
|
: []),
|
|
658
666
|
],
|
|
659
667
|
workspaces: workspaceStacks,
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectPackageManager = detectPackageManager;
|
|
4
|
+
/**
|
|
5
|
+
* Manifests outside Node and Python, each one a file this analyzer already parses.
|
|
6
|
+
*
|
|
7
|
+
* Order matters only where two can coexist: a Gradle build with a `pom.xml` beside it is
|
|
8
|
+
* built by Gradle, and a `.csproj` next to a `packages.config` is NuGet either way.
|
|
9
|
+
*
|
|
10
|
+
* Read from the file list rather than from the workspace record, because the workspace
|
|
11
|
+
* record was built for Node and Python and knows nothing about these — which is the
|
|
12
|
+
* reason a Flutter project came out as `unknown` in the first place.
|
|
13
|
+
*/
|
|
14
|
+
const OTHER_MANIFESTS = [
|
|
15
|
+
{ manager: 'pub', pattern: /(^|\/)pubspec\.yaml$/ },
|
|
16
|
+
{ manager: 'composer', pattern: /(^|\/)composer\.json$/ },
|
|
17
|
+
{ manager: 'go modules', pattern: /(^|\/)go\.mod$/ },
|
|
18
|
+
{ manager: 'bundler', pattern: /(^|\/)Gemfile$/ },
|
|
19
|
+
{ manager: 'gradle', pattern: /(^|\/)build\.gradle(\.kts)?$/ },
|
|
20
|
+
{ manager: 'maven', pattern: /(^|\/)pom\.xml$/ },
|
|
21
|
+
{ manager: 'nuget', pattern: /\.(csproj|fsproj|vbproj)$/i },
|
|
22
|
+
];
|
|
23
|
+
function resolveFromOtherManifests(files) {
|
|
24
|
+
for (const { manager, pattern } of OTHER_MANIFESTS) {
|
|
25
|
+
const match = files.find((file) => pattern.test(file));
|
|
26
|
+
if (match)
|
|
27
|
+
return { manager, confidence: 'manifest', warnings: [], evidence: [match] };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
4
31
|
function resolveWorkspaceManager(workspace) {
|
|
5
32
|
const warnings = [];
|
|
6
33
|
if (workspace.lockfiles.includes('pnpm-lock.yaml')) {
|
|
@@ -39,7 +66,12 @@ async function detectPackageManager(ctx) {
|
|
|
39
66
|
};
|
|
40
67
|
});
|
|
41
68
|
const rootWorkspace = workspaceManagers.find((w) => w.root === '.');
|
|
42
|
-
const
|
|
69
|
+
const fromWorkspaces = rootWorkspace ?? workspaceManagers.find((w) => w.manager !== 'unknown');
|
|
70
|
+
// Node and Python first, because the workspace record is built from their manifests and
|
|
71
|
+
// knows which of several it found. Anything else is resolved from the file list.
|
|
72
|
+
const selected = fromWorkspaces && fromWorkspaces.manager !== 'unknown'
|
|
73
|
+
? fromWorkspaces
|
|
74
|
+
: resolveFromOtherManifests(ctx.files.all) ?? fromWorkspaces;
|
|
43
75
|
if (selected) {
|
|
44
76
|
return {
|
|
45
77
|
manager: selected.manager,
|
package/dist/analyzer/types.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The names this tool can put to how a project declares its dependencies.
|
|
3
|
+
*
|
|
4
|
+
* It held seven, all of them Node or Python, so a Flutter application with a
|
|
5
|
+
* `pubspec.yaml` in its root reported "Package manager: unknown (unknown)" — in the
|
|
6
|
+
* same summary that had just read that file to identify Flutter. Five of the
|
|
7
|
+
* seventy-eight repositories in the verification corpus were in that position, across
|
|
8
|
+
* Dart, PHP, Kotlin and C#.
|
|
9
|
+
*
|
|
10
|
+
* Only managers whose manifest this analyzer actually reads are named here. A name it
|
|
11
|
+
* cannot back with a parsed file would be a guess from a filename.
|
|
12
|
+
*/
|
|
13
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'pip' | 'poetry' | 'python' | 'pub' | 'composer' | 'go modules' | 'bundler' | 'gradle' | 'maven' | 'nuget' | 'unknown';
|
|
2
14
|
export type PackageManagerConfidence = 'lockfile' | 'manifest' | 'inferred' | 'unknown';
|
|
3
15
|
export interface WorkspaceStack {
|
|
4
16
|
root: string;
|
package/dist/cli.js
CHANGED
|
@@ -42,10 +42,20 @@ const buildReport_1 = require("./report/buildReport");
|
|
|
42
42
|
const markdownReport_1 = require("./report/markdownReport");
|
|
43
43
|
const jsonReport_1 = require("./report/jsonReport");
|
|
44
44
|
const buildPlan_1 = require("./planner/buildPlan");
|
|
45
|
+
const productProfiles_1 = require("./expectations/productProfiles");
|
|
45
46
|
const markdownPlan_1 = require("./planner/markdownPlan");
|
|
46
47
|
const jsonPlan_1 = require("./planner/jsonPlan");
|
|
47
48
|
const pathUtils_1 = require("./utils/pathUtils");
|
|
48
49
|
const version_1 = require("./version");
|
|
50
|
+
/**
|
|
51
|
+
* The profiles this tool actually has, read from the same table the analyzer uses.
|
|
52
|
+
*
|
|
53
|
+
* The list was typed out by hand and named eight of them: `library`, `game`,
|
|
54
|
+
* `client-app` and `mobile-app` had been added since, so a user asking `--help` which
|
|
55
|
+
* profiles exist was told four fewer than they could pass — including every profile that
|
|
56
|
+
* covers something other than a web application.
|
|
57
|
+
*/
|
|
58
|
+
const PROFILE_OPTION_LIST = (0, productProfiles_1.productProfileChoices)().map((choice) => choice.id).join('|');
|
|
49
59
|
const allowedProfiles = ['observed-only', 'static-site', 'internal-tool', 'b2c-app', 'b2b-saas', 'ai-saas', 'marketplace', 'game', 'client-app', 'mobile-app', 'library', 'auto'];
|
|
50
60
|
const maturityOrder = ['prototype', 'early', 'partial', 'production_ready'];
|
|
51
61
|
function normalizeProfile(profile) {
|
|
@@ -128,7 +138,15 @@ function summarize(report) {
|
|
|
128
138
|
report.detectedStack.warnings.length > 0 ? `Warnings: ${report.detectedStack.warnings.join('; ')}` : 'Warnings: none',
|
|
129
139
|
`Workspaces: ${workspaceSummary}`,
|
|
130
140
|
`Score: ${report.overallScore}/100`,
|
|
131
|
-
|
|
141
|
+
/**
|
|
142
|
+
* The reasons, not a verdict about the project.
|
|
143
|
+
*
|
|
144
|
+
* This said "project not recognized" whatever the cause, two lines under
|
|
145
|
+
* "Detected frontend: flutter" — the same summary naming the stack it had just
|
|
146
|
+
* failed to recognise. The report has carried the reasons since the flag existed.
|
|
147
|
+
*/
|
|
148
|
+
`Maturity: ${report.maturityLevel}${report.inconclusive ? ' (INCONCLUSIVE, score capped)' : ''}`,
|
|
149
|
+
...(report.inconclusive ? report.inconclusiveReasons.map((reason) => ` - ${reason}`) : []),
|
|
132
150
|
// Printed next to the score it is not allowed to change, so a reader who knows what
|
|
133
151
|
// their project is can act on it in one step.
|
|
134
152
|
...(report.productProfile?.profileSuggestion
|
|
@@ -211,9 +229,10 @@ async function runCli(argv = process.argv) {
|
|
|
211
229
|
.version(version_1.PRODKit_VERSION);
|
|
212
230
|
program
|
|
213
231
|
.command('analyze')
|
|
232
|
+
.description('Read a repository and report what production would demand of it.')
|
|
214
233
|
.argument('<path-to-project>', 'Path to target repository')
|
|
215
234
|
.option('--format <format>', 'Output format: markdown|json')
|
|
216
|
-
.option('--profile <profile>',
|
|
235
|
+
.option('--profile <profile>', `Product profile: ${PROFILE_OPTION_LIST}`)
|
|
217
236
|
.option('--summary', 'Print summary only')
|
|
218
237
|
.option('--output <path>', 'Output file path (optional)')
|
|
219
238
|
.option('--fail-under <score>', 'Exit with an error if overall score is below this threshold (0-100)')
|
|
@@ -247,9 +266,10 @@ async function runCli(argv = process.argv) {
|
|
|
247
266
|
});
|
|
248
267
|
program
|
|
249
268
|
.command('plan')
|
|
269
|
+
.description('Turn a report into an ordered list of remediation tasks.')
|
|
250
270
|
.argument('<path-to-project>', 'Path to target repository')
|
|
251
271
|
.option('--format <format>', 'Output format: markdown|json')
|
|
252
|
-
.option('--profile <profile>',
|
|
272
|
+
.option('--profile <profile>', `Product profile: ${PROFILE_OPTION_LIST}`)
|
|
253
273
|
.option('--summary', 'Print summary only')
|
|
254
274
|
.option('--output <path>', 'Output file path (optional)')
|
|
255
275
|
.action(async (targetPath, options) => {
|
|
@@ -154,7 +154,21 @@ function buildReport(analysis, options) {
|
|
|
154
154
|
* verdicts or fewer and thirty reach ten or more; not one lands in between. Four
|
|
155
155
|
* verdicts cannot characterise a product, however many files it has.
|
|
156
156
|
*/
|
|
157
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Only where the analysis was asked for a full reading.
|
|
159
|
+
*
|
|
160
|
+
* In observed-only mode nothing but the rules runs, and a small project reaches three
|
|
161
|
+
* or four verdicts because the expectations that would produce the rest were never
|
|
162
|
+
* requested — not because it is unreadable. The command-line tool defaults to that
|
|
163
|
+
* mode, and this rule, written against the profile path, turned twenty-three of its
|
|
164
|
+
* reports into "project not recognized": a Flutter application, a Vue application, a
|
|
165
|
+
* .NET game, each of them named on the line above by the same summary.
|
|
166
|
+
*
|
|
167
|
+
* Found by running the tool the way somebody who installed it would, rather than by
|
|
168
|
+
* calling the API the way the tests do.
|
|
169
|
+
*/
|
|
170
|
+
const tooLittleAssessed = requestedProfile !== 'observed-only'
|
|
171
|
+
&& assessedChecks < score_1.MIN_ASSESSED_FOR_A_READING;
|
|
158
172
|
const inconclusive = nothingIdentified || tooLittleAssessed;
|
|
159
173
|
// Each reason says which of the two it was, because they call for different things:
|
|
160
174
|
// one is a repository this analyzer cannot read, the other is one there is barely
|
package/dist/rules/rules.js
CHANGED
|
@@ -203,9 +203,24 @@ exports.rules = [
|
|
|
203
203
|
category: 'env',
|
|
204
204
|
status,
|
|
205
205
|
severity: sevForStatus(status, 'medium'),
|
|
206
|
+
/**
|
|
207
|
+
* One sentence per answer.
|
|
208
|
+
*
|
|
209
|
+
* "Environment template looks available or env usage was not detected" is two
|
|
210
|
+
* different findings sharing a line, and sixty-two of the seventy-eight
|
|
211
|
+
* repositories in the corpus were shown it: nineteen that have a template, and
|
|
212
|
+
* forty-three that read no environment variables at all. A reader cannot tell
|
|
213
|
+
* which one they are, and those call for opposite actions — one is done, the
|
|
214
|
+
* other was never asked.
|
|
215
|
+
*
|
|
216
|
+
* The status already knew. This is the same mistake the privacy check was
|
|
217
|
+
* corrected for, still being made one rule above it.
|
|
218
|
+
*/
|
|
206
219
|
description: missing
|
|
207
220
|
? 'The project reads environment variables but no .env.example was detected.'
|
|
208
|
-
:
|
|
221
|
+
: status === 'passed'
|
|
222
|
+
? 'An environment template is present alongside the variables this project reads.'
|
|
223
|
+
: 'Nothing here reads environment variables, so there is no template to check.',
|
|
209
224
|
recommendation: 'Provide a safe .env.example containing required keys and no secrets.',
|
|
210
225
|
evidence: det?.evidence ?? [],
|
|
211
226
|
});
|
|
@@ -338,7 +353,19 @@ exports.rules = [
|
|
|
338
353
|
category: 'security',
|
|
339
354
|
status,
|
|
340
355
|
severity: sevForStatus(status, 'medium'),
|
|
341
|
-
|
|
356
|
+
/**
|
|
357
|
+
* `unknown` here means the check does not apply — this detector only reads
|
|
358
|
+
* Express middleware, and a Django project has rate limiting somewhere it cannot
|
|
359
|
+
* see. Saying "no rate limiting detected" for that is reporting the analyzer's
|
|
360
|
+
* blind spot as the project's gap.
|
|
361
|
+
*/
|
|
362
|
+
description: status === 'passed'
|
|
363
|
+
? 'Rate limiting signals detected on the authentication surface.'
|
|
364
|
+
: status === 'missing'
|
|
365
|
+
? 'No auth-focused rate limiting detected.'
|
|
366
|
+
: !hasAuth
|
|
367
|
+
? 'Nothing here authenticates anybody, so there is no login surface to throttle.'
|
|
368
|
+
: 'This check reads Express middleware, and this project does not use it — any throttling it has is somewhere this cannot see.',
|
|
342
369
|
recommendation: 'Apply express-rate-limit (or equivalent) to login/register/password reset endpoints.',
|
|
343
370
|
/**
|
|
344
371
|
* The claim is about rate limiting, so the evidence is about rate limiting.
|
|
@@ -453,7 +480,9 @@ exports.rules = [
|
|
|
453
480
|
? 'Uploads look publicly exposed without auth checks.'
|
|
454
481
|
: status === 'partial'
|
|
455
482
|
? 'Uploads are exposed and some auth signals exist, review route protection.'
|
|
456
|
-
:
|
|
483
|
+
: status === 'passed'
|
|
484
|
+
? 'Upload handling was found, and none of it is publicly exposed.'
|
|
485
|
+
: 'No upload handling was found in this repository.',
|
|
457
486
|
recommendation: 'Protect upload routes with authz, validate MIME/type, and prefer private object storage.',
|
|
458
487
|
evidence: up?.evidence ?? [],
|
|
459
488
|
});
|
|
@@ -475,7 +504,13 @@ exports.rules = [
|
|
|
475
504
|
category: 'auth',
|
|
476
505
|
status,
|
|
477
506
|
severity: sevForStatus(status, 'medium'),
|
|
478
|
-
description: status === 'missing'
|
|
507
|
+
description: status === 'missing'
|
|
508
|
+
? 'No clear authentication signals detected.'
|
|
509
|
+
: status === 'unknown'
|
|
510
|
+
? 'Nothing in this repository serves requests or holds accounts, so there is nobody here to authenticate.'
|
|
511
|
+
: status === 'partial'
|
|
512
|
+
? 'Authentication signals detected, but not a complete flow.'
|
|
513
|
+
: 'Authentication signals detected.',
|
|
479
514
|
recommendation: 'Implement robust auth flow and secure credential handling.',
|
|
480
515
|
evidence: auth?.evidence ?? [],
|
|
481
516
|
});
|
|
@@ -515,7 +550,11 @@ exports.rules = [
|
|
|
515
550
|
? 'Permission-level authorization signals detected.'
|
|
516
551
|
: status === 'partial'
|
|
517
552
|
? 'Only basic role checks detected.'
|
|
518
|
-
:
|
|
553
|
+
: status === 'missing'
|
|
554
|
+
? 'No resource-level authorization signals detected.'
|
|
555
|
+
: !hasAuth
|
|
556
|
+
? 'Nothing here authenticates anybody, so there are no callers to authorize.'
|
|
557
|
+
: 'Nothing in this repository serves requests, so there are no records to guard.',
|
|
519
558
|
recommendation: 'Add policy/resource-level checks beyond coarse role gates.',
|
|
520
559
|
evidence: [...(roles?.evidence ?? []), ...(permissions?.evidence ?? []), ...(resourceLevel?.evidence ?? [])],
|
|
521
560
|
});
|
|
@@ -598,9 +637,15 @@ exports.rules = [
|
|
|
598
637
|
category: 'gdpr',
|
|
599
638
|
status,
|
|
600
639
|
severity: sevForStatus(status, 'medium'),
|
|
601
|
-
description:
|
|
640
|
+
description: status === 'missing'
|
|
602
641
|
? 'Auth/users signals found but no GDPR/privacy controls detected.'
|
|
603
|
-
:
|
|
642
|
+
: status === 'passed'
|
|
643
|
+
? 'Consent, export, erasure or retention controls detected.'
|
|
644
|
+
: hasGdpr
|
|
645
|
+
? 'Privacy controls were found, but nothing here serves requests or holds accounts for them to apply to.'
|
|
646
|
+
: !hasUserFacingSurface(analysis)
|
|
647
|
+
? 'Nothing in this repository serves requests or holds accounts, so there is no personal data flow here to check.'
|
|
648
|
+
: 'No accounts or user records were detected, so there is nothing here for these duties to attach to.',
|
|
604
649
|
recommendation: 'Implement consent, export/erasure workflows, and retention policies.',
|
|
605
650
|
evidence: [
|
|
606
651
|
...(consent?.evidence ?? []),
|
|
@@ -771,7 +816,9 @@ exports.rules = [
|
|
|
771
816
|
? 'Nothing in this repository is deployed as a running service, so how it reaches people — a registry, a store, a host — is decided outside it.'
|
|
772
817
|
: status === 'missing'
|
|
773
818
|
? 'No meaningful deployment artifacts or prod/runtime signals detected.'
|
|
774
|
-
:
|
|
819
|
+
: status === 'partial'
|
|
820
|
+
? 'Deployment artifacts detected, but the production-aware runtime settings are incomplete.'
|
|
821
|
+
: 'Deployment artifacts and production-aware runtime settings detected.',
|
|
775
822
|
/**
|
|
776
823
|
* Graceful shutdown is left out where there is no signal to catch: PHP-FPM and
|
|
777
824
|
* CGI hand each request to a worker that exits when it is done, and telling a
|
|
@@ -814,11 +861,13 @@ exports.rules = [
|
|
|
814
861
|
category: 'deployment',
|
|
815
862
|
status,
|
|
816
863
|
severity: sevForStatus(status, 'low'),
|
|
817
|
-
description:
|
|
818
|
-
? '
|
|
819
|
-
:
|
|
820
|
-
? '
|
|
821
|
-
:
|
|
864
|
+
description: status === 'passed'
|
|
865
|
+
? 'A Dockerfile and a compose file were found.'
|
|
866
|
+
: status === 'partial'
|
|
867
|
+
? 'Docker artifacts were found, but not the whole set — a Dockerfile without a compose file, or the reverse.'
|
|
868
|
+
: containerisable
|
|
869
|
+
? 'No Docker artifacts found.'
|
|
870
|
+
: 'Nothing here is deployed as a container.',
|
|
822
871
|
recommendation: containerisable ? 'Provide Dockerfile and healthchecks for reproducible deployments.' : '',
|
|
823
872
|
evidence: docker?.evidence ?? [],
|
|
824
873
|
});
|
|
@@ -102,6 +102,16 @@ exports.DEFAULT_IGNORE = [
|
|
|
102
102
|
'Intermediate/**',
|
|
103
103
|
'DerivedData/**',
|
|
104
104
|
'**/Pods/**',
|
|
105
|
+
/**
|
|
106
|
+
* Flutter's own generated tooling, and the word it chose for it.
|
|
107
|
+
*
|
|
108
|
+
* `ios/Flutter/ephemeral/` holds scripts Flutter writes and rewrites — one of them a
|
|
109
|
+
* Python helper for lldb. Its imports were being read as the project's dependencies,
|
|
110
|
+
* so a Dart application's report said some of its dependencies came from Python
|
|
111
|
+
* imports. The directory says what it is in its name.
|
|
112
|
+
*/
|
|
113
|
+
'**/Flutter/ephemeral/**',
|
|
114
|
+
'**/.dart_tool/**',
|
|
105
115
|
'**/Carthage/Build/**',
|
|
106
116
|
// Composer, Go modules and Bundler all install into `vendor/`. The name means the
|
|
107
117
|
// same thing in each: not ours.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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": {
|