@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.
@@ -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
- ? [`No dependency manifest was found: dependencies were read from ${inferredDependencySources.join(' and ')}, so versions are unknown.`]
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 selected = rootWorkspace ?? workspaceManagers.find((w) => w.manager !== 'unknown');
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,
@@ -1,4 +1,16 @@
1
- export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'pip' | 'poetry' | 'python' | 'unknown';
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
- `Maturity: ${report.maturityLevel}${report.inconclusive ? ' (INCONCLUSIVE: project not recognized, score capped)' : ''}`,
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>', 'Product profile: observed-only|static-site|internal-tool|b2c-app|b2b-saas|ai-saas|marketplace|auto')
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>', 'Product profile: observed-only|static-site|internal-tool|b2c-app|b2b-saas|ai-saas|marketplace|auto')
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
- const tooLittleAssessed = assessedChecks < score_1.MIN_ASSESSED_FOR_A_READING;
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
@@ -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
- : 'Environment template looks available or env usage was not detected.',
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
- description: hasRate ? 'Rate limiting signals detected.' : 'No auth-focused rate limiting detected.',
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
- : 'No obvious public upload exposure signal detected.',
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' ? 'No clear authentication signals detected.' : 'Authentication signals detected.',
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
- : 'No resource-level authorization signals detected.',
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: hasUsers && !hasGdpr
640
+ description: status === 'missing'
602
641
  ? 'Auth/users signals found but no GDPR/privacy controls detected.'
603
- : 'Privacy/GDPR signals detected or not applicable from available evidence.',
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
- : 'Deployment artifacts or runtime production signals detected.',
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: docker?.present
818
- ? 'Docker signals found.'
819
- : containerisable
820
- ? 'No Docker artifacts found.'
821
- : 'Nothing here is deployed as a container.',
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.19.1",
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": {