@produtype/core 0.29.2 → 0.31.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.
@@ -556,9 +556,12 @@ async function analyzeProject(projectPath) {
556
556
  }
557
557
  }
558
558
  const npmDeps = {};
559
+ const runtimeNpmDeps = {};
559
560
  for (const workspace of workspaces) {
560
561
  mergeDeps(npmDeps, workspace.packageJson?.dependencies);
561
562
  mergeDeps(npmDeps, workspace.packageJson?.devDependencies);
563
+ // Kept apart for one question only: whether this repository serves requests.
564
+ mergeDeps(runtimeNpmDeps, workspace.packageJson?.dependencies);
562
565
  }
563
566
  /**
564
567
  * What the code imports, when nothing declared it.
@@ -606,6 +609,7 @@ async function analyzeProject(projectPath) {
606
609
  const ctx = {
607
610
  root,
608
611
  files: { all: allFiles, source: sourceFiles, config: configFiles, unreadable: unreadableLanguages(allFiles) },
612
+ runtimeNpmDeps,
609
613
  packageJson,
610
614
  pythonDeps,
611
615
  phpDeps: unique(phpDeps),
@@ -8,8 +8,20 @@ const catalogue_1 = require("./catalogue");
8
8
  async function detectBackend(ctx) {
9
9
  const frameworks = [];
10
10
  const evidence = [];
11
+ /**
12
+ * Shipped, not merely present.
13
+ *
14
+ * axios declares `express` in devDependencies to run a test server against itself,
15
+ * and was read as having a backend — which kept a library out of the `library`
16
+ * profile. A server framework the product does not ship with is a fixture. The
17
+ * fallback below still reads the source, so a project that runs Express without
18
+ * declaring it is found anyway.
19
+ *
20
+ * Not one repository in the local corpus declares a backend framework only in
21
+ * devDependencies, so this changes nothing there and fixes a public library.
22
+ */
11
23
  // Express
12
- if ((0, detectContext_1.hasDep)(ctx, 'express')) {
24
+ if ((0, detectContext_1.hasRuntimeDep)(ctx, 'express')) {
13
25
  frameworks.push('express');
14
26
  evidence.push({ type: 'dependency', value: 'express' });
15
27
  }
@@ -30,7 +42,7 @@ async function detectBackend(ctx) {
30
42
  // frontend-only meant an application with server routes, sessions and database
31
43
  // access was scored as though it had none of them.
32
44
  for (const [framework, dep] of catalogue_1.NODE_BACKEND_FRAMEWORKS) {
33
- if ((0, detectContext_1.hasDep)(ctx, dep)) {
45
+ if ((0, detectContext_1.hasRuntimeDep)(ctx, dep)) {
34
46
  frameworks.push(framework);
35
47
  evidence.push({ type: 'dependency', value: dep });
36
48
  }
@@ -38,9 +38,25 @@ export interface DetectContext {
38
38
  swiftDeps: string[];
39
39
  /** Lowercased combined dependency map (deps + devDeps). */
40
40
  npmDeps: Record<string, string>;
41
+ /**
42
+ * Dependencies the product ships with, without the ones it only builds and tests with.
43
+ *
44
+ * `npmDeps` merges both, which is right for almost every question — React in
45
+ * devDependencies still means React — and wrong for one: a server framework. axios
46
+ * declares `express` in devDependencies to run a test server, and was read as having
47
+ * a backend, which kept it out of the `library` profile it plainly belongs to.
48
+ */
49
+ runtimeNpmDeps: Record<string, string>;
41
50
  workspaces: WorkspaceManifest[];
42
51
  }
43
52
  export declare function hasDep(ctx: DetectContext, name: string): boolean;
53
+ /**
54
+ * Declared as something the product runs on, not merely something present.
55
+ *
56
+ * Used for server frameworks only. A test server in devDependencies is a fixture; the
57
+ * question "does this repository serve requests" is answered by what it ships.
58
+ */
59
+ export declare function hasRuntimeDep(ctx: DetectContext, name: string): boolean;
44
60
  export declare function hasAnyDep(ctx: DetectContext, names: string[]): string[];
45
61
  export declare function hasPyDep(ctx: DetectContext, name: string): boolean;
46
62
  export declare function hasAnyPyDep(ctx: DetectContext, names: string[]): string[];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hasDep = hasDep;
4
+ exports.hasRuntimeDep = hasRuntimeDep;
4
5
  exports.hasAnyDep = hasAnyDep;
5
6
  exports.hasPyDep = hasPyDep;
6
7
  exports.hasAnyPyDep = hasAnyPyDep;
@@ -15,6 +16,15 @@ exports.hasAnyDotnetDep = hasAnyDotnetDep;
15
16
  function hasDep(ctx, name) {
16
17
  return Object.prototype.hasOwnProperty.call(ctx.npmDeps, name.toLowerCase());
17
18
  }
19
+ /**
20
+ * Declared as something the product runs on, not merely something present.
21
+ *
22
+ * Used for server frameworks only. A test server in devDependencies is a fixture; the
23
+ * question "does this repository serve requests" is answered by what it ships.
24
+ */
25
+ function hasRuntimeDep(ctx, name) {
26
+ return Object.prototype.hasOwnProperty.call(ctx.runtimeNpmDeps, name.toLowerCase());
27
+ }
18
28
  function hasAnyDep(ctx, names) {
19
29
  return names.filter((n) => hasDep(ctx, n));
20
30
  }
@@ -133,6 +133,63 @@ async function androidGradleFiles(ctx) {
133
133
  }
134
134
  return found;
135
135
  }
136
+ /**
137
+ * Directory names that are part of a platform's layout rather than the project's.
138
+ *
139
+ * `src/ClientApp/Platforms/Android/AndroidManifest.xml` belongs to a project called
140
+ * ClientApp; the three segments after it are how MAUI arranges a project, not where the
141
+ * project begins. Walking past them finds the root a person would name.
142
+ */
143
+ const PLATFORM_LAYOUT = new Set([
144
+ 'platforms', 'android', 'ios', 'maccatalyst', 'app', 'src', 'main', 'res', 'xcshareddata',
145
+ ]);
146
+ function mobileProjectRoot(marker) {
147
+ const segments = marker.split('/').slice(0, -1);
148
+ /**
149
+ * `.xcodeproj` sits beside the sources, not above them.
150
+ *
151
+ * `DonGeremIA.xcodeproj/project.pbxproj` names a project whose Swift files are in
152
+ * `DonGeremIA/`, a sibling. Stopping at the bundle put the project root somewhere no
153
+ * source file lives and scored a Swift application at zero.
154
+ */
155
+ while (segments.length > 0
156
+ && (PLATFORM_LAYOUT.has(segments[segments.length - 1].toLowerCase())
157
+ || /\.(xcodeproj|xcworkspace)$/i.test(segments[segments.length - 1]))) {
158
+ segments.pop();
159
+ }
160
+ return segments.join('/');
161
+ }
162
+ /**
163
+ * How much of the repository the mobile project actually is.
164
+ *
165
+ * dotnet/eShop contains a real MAUI client — the markers are not a false positive — and
166
+ * it is one project of a dozen: 137 source files of 515, beside a web application and
167
+ * eight services. The repository was reported as a mobile application because a true
168
+ * signal about a part was read as a fact about the whole.
169
+ *
170
+ * Every repository in the verification corpus that *is* a phone application scores 1
171
+ * here: the manifest sits at its root. The gap between that and eShop's 0.27 is where
172
+ * the line goes.
173
+ */
174
+ function mobileSourceShare(sourceFiles, markers) {
175
+ if (sourceFiles.length === 0)
176
+ return 0;
177
+ /**
178
+ * No file marker means the platform came from a dependency.
179
+ *
180
+ * A Flutter project is recognised by `flutter` in its `pubspec.yaml`, and a React
181
+ * Native one by its `package.json` — a fact about the project's own manifest rather
182
+ * than about a subdirectory. Scoring those at zero made every Flutter application in
183
+ * the corpus stop being a mobile application.
184
+ */
185
+ if (markers.length === 0)
186
+ return 1;
187
+ const roots = new Set(markers.map(mobileProjectRoot));
188
+ if (roots.has(''))
189
+ return 1;
190
+ const inside = sourceFiles.filter((file) => [...roots].some((root) => file.startsWith(`${root}/`)));
191
+ return inside.length / sourceFiles.length;
192
+ }
136
193
  async function detectMobile(ctx) {
137
194
  const iosFiles = matchesAny(ctx.files.all, IOS_MARKERS);
138
195
  const androidFiles = [...matchesAny(ctx.files.all, ANDROID_MARKERS), ...(await androidGradleFiles(ctx))];
@@ -158,6 +215,7 @@ async function detectMobile(ctx) {
158
215
  platformEvidence.push({ type: 'file', value: androidFiles[0], file: androidFiles[0] });
159
216
  }
160
217
  const isMobile = platforms.length > 0;
218
+ const share = mobileSourceShare(ctx.files.source, [...iosFiles, ...androidFiles]);
161
219
  /**
162
220
  * Every capability below reports `present: false` when this is not a mobile project,
163
221
  * with no evidence — the profile that asks about them is the only one that applies
@@ -286,7 +344,7 @@ async function detectMobile(ctx) {
286
344
  key: 'mobile.platform',
287
345
  present: true,
288
346
  evidence: platformEvidence,
289
- details: { platforms },
347
+ details: { platforms, sourceShare: share },
290
348
  },
291
349
  {
292
350
  key: 'mobile.permissions',
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectPackaging = detectPackaging;
4
4
  const readTextFileSafe_1 = require("../utils/readTextFileSafe");
5
+ const detectContext_1 = require("./detectContext");
5
6
  /**
6
7
  * Whether a project is fit to be installed and depended on by someone else.
7
8
  *
@@ -30,8 +31,48 @@ const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|
30
31
  function collect(files, pattern, limit = 3) {
31
32
  return files.filter((file) => pattern.test(file)).slice(0, limit);
32
33
  }
34
+ /**
35
+ * Generators whose whole job is to build a documentation website.
36
+ *
37
+ * A package with a docs site in its repository was being read as whatever that site is
38
+ * built with. zod is a validation library and came back a client application; vite came
39
+ * back a client application; ruff — a linter written in Rust, with every packaging
40
+ * signal present — came back a static site, read from its React playground. Five public
41
+ * libraries out of five, all for this reason.
42
+ *
43
+ * The site that documents a product is not the product.
44
+ */
45
+ const DOCS_GENERATORS = [
46
+ '@docusaurus/core', 'vitepress', 'nextra', '@astrojs/starlight', 'vuepress', 'docz',
47
+ 'docsify-cli', 'mintlify', '@11ty/eleventy', 'mkdocs', 'mkdocs-material', 'sphinx',
48
+ ];
49
+ /**
50
+ * Where a documentation site lives when it has no generator of its own.
51
+ *
52
+ * A hand-built docs site or a playground sits in a directory named for what it is. This
53
+ * is only consulted for the front-end files: a `docs/` folder full of Markdown says
54
+ * nothing either way, and a repository whose application happens to live under `www/`
55
+ * is not caught because the question asked is whether *every* front-end file is in one
56
+ * of these.
57
+ */
58
+ const DOCS_DIRECTORIES = /^(docs?|website|playground|examples?|demo|www)\//i;
59
+ const FRONTEND_FILE = /\.(tsx|jsx|vue|svelte|astro)$/;
33
60
  async function detectPackaging(ctx) {
34
61
  const all = ctx.files.all;
62
+ /**
63
+ * Whether the front end in this repository is its documentation rather than its product.
64
+ *
65
+ * Two ways to know, and both have to be about the front end specifically. A docs
66
+ * generator in the dependencies says so outright. Failing that, every front-end file
67
+ * living under a directory named for documentation says the same thing more quietly —
68
+ * and *every* matters: one component under `examples/` beside an application is an
69
+ * example, while an application that is entirely under `examples/` does not exist.
70
+ */
71
+ const docsGenerators = [...(0, detectContext_1.hasAnyDep)(ctx, DOCS_GENERATORS), ...(0, detectContext_1.hasAnyPyDep)(ctx, DOCS_GENERATORS)];
72
+ const frontendFiles = ctx.files.source.filter((file) => FRONTEND_FILE.test(file));
73
+ const frontendAllInDocs = frontendFiles.length > 0
74
+ && frontendFiles.every((file) => DOCS_DIRECTORIES.test(file));
75
+ const documentationSite = docsGenerators.length > 0 || frontendAllInDocs;
35
76
  const licenseFiles = collect(all, LICENSE_FILE);
36
77
  const declaredLicense = typeof ctx.packageJson?.license === 'string';
37
78
  const licenseEvidence = [
@@ -119,6 +160,21 @@ async function detectPackaging(ctx) {
119
160
  evidence: licenseEvidence,
120
161
  details: { files: licenseFiles.length, declared: declaredLicense },
121
162
  },
163
+ {
164
+ /**
165
+ * A site that documents the product, as distinct from the product.
166
+ *
167
+ * Its own detector rather than a flag on the stack, because it is evidence a
168
+ * reader can check: either a generator in the manifest or the directory every
169
+ * front-end file sits in.
170
+ */
171
+ key: 'docs.site',
172
+ present: documentationSite,
173
+ evidence: docsGenerators.length > 0
174
+ ? docsGenerators.map((dep) => ({ type: 'dependency', value: dep }))
175
+ : frontendFiles.slice(0, 5).map((file) => ({ type: 'file', value: file, file })),
176
+ details: { generators: docsGenerators, frontendFiles: frontendFiles.length, allUnderDocs: frontendAllInDocs },
177
+ },
122
178
  {
123
179
  key: 'docs.readme',
124
180
  present: readmeFiles.length > 0,
@@ -49,6 +49,14 @@ export interface ProfileFacts {
49
49
  gameEngine: boolean;
50
50
  /** A manifest that names and versions this, so something else can depend on it. */
51
51
  publishable: boolean;
52
+ /**
53
+ * The front end in this repository is its documentation, not its product.
54
+ *
55
+ * zod, axios, vite and ruff were all read from their docs sites: five public libraries
56
+ * out of five, none identified as a library. The site that documents a product is not
57
+ * the product.
58
+ */
59
+ documentationSite: boolean;
52
60
  /** Somewhere for a consumer to import: main, module, exports, bin, a console script. */
53
61
  entrypoints: boolean;
54
62
  packagedLicense: boolean;
@@ -56,6 +64,14 @@ export interface ProfileFacts {
56
64
  gameSignals: number;
57
65
  /** Ships to a phone: Flutter, React Native, or an iOS/Android project in the tree. */
58
66
  mobilePlatforms: string[];
67
+ /**
68
+ * How much of the repository the mobile project is, between 0 and 1.
69
+ *
70
+ * A true signal about one project was being read as a fact about the whole: eShop's
71
+ * MAUI client is 137 source files of 515, beside a web application and eight services,
72
+ * and the repository came back as a phone application.
73
+ */
74
+ mobileShare: number;
59
75
  sourceFiles: number;
60
76
  }
61
77
  export declare function readFacts(analysis: ProjectAnalysis): ProfileFacts;
@@ -28,11 +28,13 @@ function readFacts(analysis) {
28
28
  containerised: present('deployment.docker'),
29
29
  gameEngine: present('game.engine'),
30
30
  publishable: complete('packaging.manifest'),
31
+ documentationSite: present('docs.site'),
31
32
  entrypoints: present('packaging.entrypoints'),
32
33
  packagedLicense: present('packaging.license'),
33
34
  tests: present('quality.tests'),
34
35
  gameSignals: Number(analysis.detectors['game.engine']?.details?.supportingSignals ?? 0),
35
36
  mobilePlatforms: analysis.detectors['mobile.platform']?.details?.platforms ?? [],
37
+ mobileShare: analysis.detectors['mobile.platform']?.details?.sourceShare ?? 0,
36
38
  sourceFiles: analysis.files.source.length,
37
39
  };
38
40
  }
@@ -134,7 +136,15 @@ const RULES = [
134
136
  */
135
137
  profile: 'mobile-app',
136
138
  refines: 'client-app',
137
- admissible: (f) => f.mobilePlatforms.length > 0 && !f.tenancy,
139
+ /**
140
+ * The mobile project has to be the repository, not a project inside it.
141
+ *
142
+ * Every repository in the corpus that is a phone application has its manifest at the
143
+ * root and scores 1. eShop scores 0.27 and is a .NET system with a MAUI client in it.
144
+ * Half is the line, and it sits in the gap between those two rather than in the
145
+ * middle of a distribution.
146
+ */
147
+ admissible: (f) => f.mobilePlatforms.length > 0 && f.mobileShare >= 0.5 && !f.tenancy,
138
148
  signals: [
139
149
  { identifies: true, label: 'a mobile project in the repository', weight: 5, holds: (f) => f.mobilePlatforms.length > 0 },
140
150
  {
@@ -175,7 +185,18 @@ const RULES = [
175
185
  * project serves requests, and a package is not served.
176
186
  */
177
187
  profile: 'library',
178
- admissible: (f) => f.publishable && f.entrypoints && !f.frontend && !f.backend && f.mobilePlatforms.length === 0,
188
+ /**
189
+ * A front end that is documentation does not make a package an application.
190
+ *
191
+ * The gate used to be "no front end at all", which is right for a plain package and
192
+ * wrong for every library with a docs site — which is to say, every library anybody
193
+ * has heard of. A backend still disqualifies: a package is not served.
194
+ */
195
+ admissible: (f) => f.publishable
196
+ && f.entrypoints
197
+ && (!f.frontend || f.documentationSite)
198
+ && !f.backend
199
+ && f.mobilePlatforms.length === 0,
179
200
  signals: [
180
201
  { identifies: true, label: 'a manifest that names and versions it', weight: 3, holds: (f) => f.publishable },
181
202
  { identifies: true, label: 'an entry point for something else to import', weight: 2, holds: (f) => f.entrypoints },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.29.2",
3
+ "version": "0.31.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": {