@produtype/core 0.30.0 → 0.32.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
  }
@@ -1,3 +1,13 @@
1
1
  import type { DetectorResult } from './types';
2
2
  import type { DetectContext } from './detectContext';
3
+ /**
4
+ * Where a documentation site lives when it has no generator of its own.
5
+ *
6
+ * A hand-built docs site or a playground sits in a directory named for what it is. This
7
+ * is only consulted for the front-end files: a `docs/` folder full of Markdown says
8
+ * nothing either way, and a repository whose application happens to live under `www/`
9
+ * is not caught because the question asked is whether *every* front-end file is in one
10
+ * of these.
11
+ */
12
+ export declare const DOCS_DIRECTORIES: RegExp;
3
13
  export declare function detectPackaging(ctx: DetectContext): Promise<DetectorResult[]>;
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DOCS_DIRECTORIES = void 0;
3
4
  exports.detectPackaging = detectPackaging;
4
5
  const readTextFileSafe_1 = require("../utils/readTextFileSafe");
6
+ const detectContext_1 = require("./detectContext");
7
+ const readTextFileSafe_2 = require("../utils/readTextFileSafe");
5
8
  /**
6
9
  * Whether a project is fit to be installed and depended on by someone else.
7
10
  *
@@ -30,8 +33,69 @@ const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|
30
33
  function collect(files, pattern, limit = 3) {
31
34
  return files.filter((file) => pattern.test(file)).slice(0, limit);
32
35
  }
36
+ /**
37
+ * Generators whose whole job is to build a documentation website.
38
+ *
39
+ * A package with a docs site in its repository was being read as whatever that site is
40
+ * built with. zod is a validation library and came back a client application; vite came
41
+ * back a client application; ruff — a linter written in Rust, with every packaging
42
+ * signal present — came back a static site, read from its React playground. Five public
43
+ * libraries out of five, all for this reason.
44
+ *
45
+ * The site that documents a product is not the product.
46
+ */
47
+ const DOCS_GENERATORS = [
48
+ '@docusaurus/core', 'vitepress', 'nextra', '@astrojs/starlight', 'vuepress', 'docz',
49
+ 'docsify-cli', 'mintlify', '@11ty/eleventy', 'mkdocs', 'mkdocs-material', 'sphinx',
50
+ ];
51
+ /**
52
+ * Where a documentation site lives when it has no generator of its own.
53
+ *
54
+ * A hand-built docs site or a playground sits in a directory named for what it is. This
55
+ * is only consulted for the front-end files: a `docs/` folder full of Markdown says
56
+ * nothing either way, and a repository whose application happens to live under `www/`
57
+ * is not caught because the question asked is whether *every* front-end file is in one
58
+ * of these.
59
+ */
60
+ exports.DOCS_DIRECTORIES = /(^|\/)(docs?|website|playground|examples?|demo|www)\//i;
61
+ const FRONTEND_FILE = /\.(tsx|jsx|vue|svelte|astro)$/;
62
+ /**
63
+ * The published package inside a workspace, if there is one.
64
+ *
65
+ * Named, versioned, not private — the three things npm requires to accept a publish.
66
+ * A workspace full of private example applications has none, and gets the honest answer
67
+ * that nothing here is published.
68
+ */
69
+ async function findPublishedMember(ctx) {
70
+ const members = ctx.files.all
71
+ .filter((file) => /(^|\/)package\.json$/.test(file) && file !== 'package.json')
72
+ .slice(0, 60);
73
+ for (const file of members) {
74
+ const manifest = await (0, readTextFileSafe_2.readJsonSafe)(ctx.root, file);
75
+ if (!manifest)
76
+ continue;
77
+ const named = typeof manifest.name === 'string' && typeof manifest.version === 'string';
78
+ if (named && manifest.private !== true)
79
+ return { path: file, manifest };
80
+ }
81
+ return null;
82
+ }
33
83
  async function detectPackaging(ctx) {
34
84
  const all = ctx.files.all;
85
+ /**
86
+ * Whether the front end in this repository is its documentation rather than its product.
87
+ *
88
+ * Two ways to know, and both have to be about the front end specifically. A docs
89
+ * generator in the dependencies says so outright. Failing that, every front-end file
90
+ * living under a directory named for documentation says the same thing more quietly —
91
+ * and *every* matters: one component under `examples/` beside an application is an
92
+ * example, while an application that is entirely under `examples/` does not exist.
93
+ */
94
+ const docsGenerators = [...(0, detectContext_1.hasAnyDep)(ctx, DOCS_GENERATORS), ...(0, detectContext_1.hasAnyPyDep)(ctx, DOCS_GENERATORS)];
95
+ const frontendFiles = ctx.files.source.filter((file) => FRONTEND_FILE.test(file));
96
+ const frontendAllInDocs = frontendFiles.length > 0
97
+ && frontendFiles.every((file) => exports.DOCS_DIRECTORIES.test(file));
98
+ const documentationSite = docsGenerators.length > 0 || frontendAllInDocs;
35
99
  const licenseFiles = collect(all, LICENSE_FILE);
36
100
  const declaredLicense = typeof ctx.packageJson?.license === 'string';
37
101
  const licenseEvidence = [
@@ -70,7 +134,33 @@ async function detectPackaging(ctx) {
70
134
  * it starts; `types` is how a TypeScript consumer finds it. A Python package says the
71
135
  * same thing through `[project.scripts]` or a `packages` argument in setup.py.
72
136
  */
73
- const pkg = (ctx.packageJson ?? {});
137
+ const rootPkg = (ctx.packageJson ?? {});
138
+ /**
139
+ * In a monorepo the root manifest is not the package.
140
+ *
141
+ * zod's root is `private: true` with `workspaces: ["packages/*"]`, no name, no version
142
+ * and no entry point; `zod` itself is `packages/zod/package.json`, named, versioned
143
+ * and exported. Reading only the root made the most downloaded validation library on
144
+ * npm a repository that publishes nothing — and vite the same.
145
+ *
146
+ * The published package is looked for among the members, and the first one that is
147
+ * named, versioned and not private is what this repository ships. Its manifest is
148
+ * what the evidence points at, so a reader can open the file the claim rests on rather
149
+ * than a root that says nothing.
150
+ */
151
+ /**
152
+ * Three ways to say the same thing.
153
+ *
154
+ * npm and yarn declare workspaces inside `package.json`; pnpm puts them in
155
+ * `pnpm-workspace.yaml`. Reading only the first meant vite — whose root has no
156
+ * `workspaces` field at all — was never looked into, and came back as a repository
157
+ * that publishes nothing.
158
+ */
159
+ const workspaceRoot = Array.isArray(rootPkg.workspaces)
160
+ || (typeof rootPkg.workspaces === 'object' && rootPkg.workspaces !== null)
161
+ || all.some((file) => /^(pnpm-workspace\.yaml|lerna\.json|nx\.json|turbo\.json)$/.test(file));
162
+ const publishedMember = workspaceRoot ? await findPublishedMember(ctx) : null;
163
+ const pkg = publishedMember?.manifest ?? rootPkg;
74
164
  const nodeEntrypoints = ['main', 'module', 'exports', 'bin'].filter((key) => pkg[key] !== undefined);
75
165
  const hasTypes = pkg.types !== undefined || pkg.typings !== undefined;
76
166
  const isPrivate = pkg.private === true;
@@ -96,7 +186,13 @@ async function detectPackaging(ctx) {
96
186
  key: 'packaging.manifest',
97
187
  present: hasManifest,
98
188
  complete: hasManifest && (named || pythonEntrypoints),
99
- evidence: hasManifest ? [{ type: 'note', value: named ? 'a manifest with a name and a version' : 'a package manifest' }] : [],
189
+ evidence: hasManifest
190
+ ? [
191
+ publishedMember
192
+ ? { type: 'file', value: `${publishedMember.path} names and versions the published package`, file: publishedMember.path }
193
+ : { type: 'note', value: named ? 'a manifest with a name and a version' : 'a package manifest' },
194
+ ]
195
+ : [],
100
196
  details: { named, described, sourced, private: isPrivate },
101
197
  },
102
198
  {
@@ -119,6 +215,21 @@ async function detectPackaging(ctx) {
119
215
  evidence: licenseEvidence,
120
216
  details: { files: licenseFiles.length, declared: declaredLicense },
121
217
  },
218
+ {
219
+ /**
220
+ * A site that documents the product, as distinct from the product.
221
+ *
222
+ * Its own detector rather than a flag on the stack, because it is evidence a
223
+ * reader can check: either a generator in the manifest or the directory every
224
+ * front-end file sits in.
225
+ */
226
+ key: 'docs.site',
227
+ present: documentationSite,
228
+ evidence: docsGenerators.length > 0
229
+ ? docsGenerators.map((dep) => ({ type: 'dependency', value: dep }))
230
+ : frontendFiles.slice(0, 5).map((file) => ({ type: 'file', value: file, file })),
231
+ details: { generators: docsGenerators, frontendFiles: frontendFiles.length, allUnderDocs: frontendAllInDocs },
232
+ },
122
233
  {
123
234
  key: 'docs.readme',
124
235
  present: readmeFiles.length > 0,
@@ -49,6 +49,22 @@ 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;
60
+ /**
61
+ * A server the product ships, as distinct from one its documentation or playgrounds run.
62
+ *
63
+ * zod's only workspace with a backend is `packages/docs`. vite's are ten directories
64
+ * under `playground/`. Both were excluded from the `library` profile by a backend
65
+ * neither of them ships.
66
+ */
67
+ productBackend: boolean;
52
68
  /** Somewhere for a consumer to import: main, module, exports, bin, a console script. */
53
69
  entrypoints: boolean;
54
70
  packagedLicense: boolean;
@@ -2,6 +2,25 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.readFacts = readFacts;
4
4
  exports.scoreProfiles = scoreProfiles;
5
+ const detectPackaging_1 = require("../analyzer/detectPackaging");
6
+ /**
7
+ * Whether any backend in this repository belongs to the product.
8
+ *
9
+ * A workspace under `docs/`, `playground/` or `examples/` that runs a server is running
10
+ * it to demonstrate or test something. vite has ten such workspaces, every one of them
11
+ * an Express server, and none of them vite.
12
+ *
13
+ * Where no workspace declares a backend but the repository does, the signal came from
14
+ * the root and belongs to the product: there is nowhere else for it to come from.
15
+ */
16
+ function hasProductBackend(analysis) {
17
+ if (analysis.stack.backend.length === 0)
18
+ return false;
19
+ const withBackend = analysis.workspaceStacks.filter((workspace) => workspace.backend.length > 0);
20
+ if (withBackend.length === 0)
21
+ return true;
22
+ return withBackend.some((workspace) => !detectPackaging_1.DOCS_DIRECTORIES.test(`${workspace.root}/`));
23
+ }
5
24
  function readFacts(analysis) {
6
25
  const sourceHints = analysis.files.source.join('\n');
7
26
  const present = (key) => analysis.detectors[key]?.present === true;
@@ -28,6 +47,8 @@ function readFacts(analysis) {
28
47
  containerised: present('deployment.docker'),
29
48
  gameEngine: present('game.engine'),
30
49
  publishable: complete('packaging.manifest'),
50
+ documentationSite: present('docs.site'),
51
+ productBackend: hasProductBackend(analysis),
31
52
  entrypoints: present('packaging.entrypoints'),
32
53
  packagedLicense: present('packaging.license'),
33
54
  tests: present('quality.tests'),
@@ -184,7 +205,18 @@ const RULES = [
184
205
  * project serves requests, and a package is not served.
185
206
  */
186
207
  profile: 'library',
187
- admissible: (f) => f.publishable && f.entrypoints && !f.frontend && !f.backend && f.mobilePlatforms.length === 0,
208
+ /**
209
+ * A front end that is documentation does not make a package an application.
210
+ *
211
+ * The gate used to be "no front end at all", which is right for a plain package and
212
+ * wrong for every library with a docs site — which is to say, every library anybody
213
+ * has heard of. A backend still disqualifies: a package is not served.
214
+ */
215
+ admissible: (f) => f.publishable
216
+ && f.entrypoints
217
+ && (!f.frontend || f.documentationSite)
218
+ && !f.productBackend
219
+ && f.mobilePlatforms.length === 0,
188
220
  signals: [
189
221
  { identifies: true, label: 'a manifest that names and versions it', weight: 3, holds: (f) => f.publishable },
190
222
  { 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.30.0",
3
+ "version": "0.32.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": {