@produtype/core 1.5.0 → 1.6.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/README.md CHANGED
@@ -191,7 +191,7 @@ prodkit plan ../my-app --output prodkit-plan.md
191
191
 
192
192
  _Generated from the analyzer itself — run `npm run docs:stacks` after changing a detector._
193
193
 
194
- - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, aiohttp, Litestar, Sanic, Tornado, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Axum, Actix Web, Rocket, Warp, Tide, Poem, Salvo, Tower HTTP, Hyper, Spring Boot, Quarkus, Micronaut, Ktor, Javalin, Vert.x, Dropwizard, Helidon, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, Cloudflare Workers
194
+ - **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, aiohttp, Litestar, Sanic, Tornado, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Axum, Actix Web, Rocket, Warp, Tide, Poem, Salvo, Tower HTTP, Hyper, Spring Boot, Quarkus, Micronaut, Ktor, Javalin, Vert.x, Dropwizard, Helidon, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Phoenix, Plug, Bandit, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, Cloudflare Workers
195
195
  - **Frontend:** React, Vite, Vue, Nuxt, Svelte, Angular, Astro, Solid, Qwik, Preact, Remix, htmx, Tailwind CSS, Electron
196
196
  - **Mobile:** Flutter, React Native, iOS (native), Android (native), SwiftUI, UIKit, Jetpack Compose, Android views
197
197
  - **Databases:** Postgres, MySQL, SQLite, SQL Server, MongoDB, Redis, Firestore, DynamoDB, Convex
@@ -25,4 +25,15 @@ export declare function searchedFor(what: string, terms: string[], claim?: strin
25
25
  * it say so, so that a reader whose roles are called `capabilities` can see why they
26
26
  * were missed and tell us we are wrong.
27
27
  */
28
+ /**
29
+ * The same, for a look that needed no reader.
30
+ *
31
+ * A search over the list of file names answers whatever language the files are
32
+ * written in. Separated so that the report can withdraw the claims a blind reader
33
+ * made without withdrawing the ones anybody could check — see
34
+ * `onlyEvidencedByASearchThatCouldNotRead` in the report.
35
+ */
36
+ export declare function searchedFileNamesFor(what: string, terms: string[], claim?: string): DetectorEvidence[];
28
37
  export declare function evidenceOrSearch(evidence: DetectorEvidence[], what: string, terms: string[], claim?: string): DetectorEvidence[];
38
+ /** `evidenceOrSearch` for a search that only had to read file names. */
39
+ export declare function evidenceOrFileNameSearch(evidence: DetectorEvidence[], what: string, terms: string[], claim?: string): DetectorEvidence[];
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.searchedFor = searchedFor;
4
+ exports.searchedFileNamesFor = searchedFileNamesFor;
4
5
  exports.evidenceOrSearch = evidenceOrSearch;
6
+ exports.evidenceOrFileNameSearch = evidenceOrFileNameSearch;
5
7
  /**
6
8
  * What was looked for, when nothing was found.
7
9
  *
@@ -36,6 +38,21 @@ function searchedFor(what, terms, claim) {
36
38
  * it say so, so that a reader whose roles are called `capabilities` can see why they
37
39
  * were missed and tell us we are wrong.
38
40
  */
41
+ /**
42
+ * The same, for a look that needed no reader.
43
+ *
44
+ * A search over the list of file names answers whatever language the files are
45
+ * written in. Separated so that the report can withdraw the claims a blind reader
46
+ * made without withdrawing the ones anybody could check — see
47
+ * `onlyEvidencedByASearchThatCouldNotRead` in the report.
48
+ */
49
+ function searchedFileNamesFor(what, terms, claim) {
50
+ return searchedFor(what, terms, claim).map((item) => ({ ...item, overFileNames: true }));
51
+ }
39
52
  function evidenceOrSearch(evidence, what, terms, claim) {
40
53
  return evidence.length > 0 ? evidence : searchedFor(what, terms, claim);
41
54
  }
55
+ /** `evidenceOrSearch` for a search that only had to read file names. */
56
+ function evidenceOrFileNameSearch(evidence, what, terms, claim) {
57
+ return evidence.length > 0 ? evidence : searchedFileNamesFor(what, terms, claim);
58
+ }
@@ -595,6 +595,51 @@ async function analyzeProject(projectPath) {
595
595
  }
596
596
  }
597
597
  }
598
+ /**
599
+ * mix.exs, read for the packages an Elixir project depends on.
600
+ *
601
+ * plausible is 1257 Elixir files and the report had no score at all: no backend, no
602
+ * database, no package manager, and an honest note saying the language was not read.
603
+ * The note was the right answer to give while it was true — but Phoenix, Ecto,
604
+ * bcrypt_elixir, cors_plug and nimble_totp are all in its manifest, and every one of
605
+ * them is a package name nobody at plausible invented.
606
+ *
607
+ * Mix declares dependencies as tuples in a function rather than as a data file:
608
+ * `{:phoenix, "~> 1.8.2"}` and `{:location, git: "..."}` are both entries, and
609
+ * `{:credo, "~> 1.7", only: [:dev, :test]}` is one the product does not ship. The
610
+ * `only:` option is the same distinction `runtimeRustDeps` draws, written in Elixir.
611
+ *
612
+ * Read from inside the `deps` function and nowhere else. A tuple beginning with an
613
+ * atom is ordinary Elixir and appears all over a manifest: plausible's release
614
+ * configuration holds `{:system, "RELEASE_ROOT", ...}` and its dialyzer settings
615
+ * `{:no_warn, "priv/plts/dialyzer.plt"}`, and both were being read as packages.
616
+ * `deps: deps()` in `project/0` is Mix's own convention, so the function is where
617
+ * the list is.
618
+ */
619
+ const elixirDeps = [];
620
+ const runtimeElixirDeps = [];
621
+ for (const file of ownManifests.filter((f) => /(^|\/)mix\.exs$/.test(f))) {
622
+ const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
623
+ /**
624
+ * An entry can run over several lines — plausible writes four of its OpenTelemetry
625
+ * dependencies with the git ref on lines of their own — so `only:` is looked for
626
+ * in the whole tuple rather than on the line that opens it.
627
+ */
628
+ const opener = /^(\s*)defp?\s+deps\s+do\s*$/m.exec(raw);
629
+ if (!opener)
630
+ continue;
631
+ const lines = raw.slice(opener.index).split('\n');
632
+ const closer = lines.findIndex((line, i) => i > 0 && line === `${opener[1]}end`);
633
+ const block = lines.slice(1, closer === -1 ? undefined : closer).join('\n');
634
+ const entries = block.matchAll(/\{\s*:([a-z][a-z0-9_]*)\s*(,[\s\S]*?)?\}/g);
635
+ for (const entry of entries) {
636
+ const name = entry[1].toLowerCase();
637
+ const options = entry[2] ?? '';
638
+ elixirDeps.push(name);
639
+ if (!/\bonly:\s*(?:\[[^\]]*\]|:[a-z_]+)/.test(options))
640
+ runtimeElixirDeps.push(name);
641
+ }
642
+ }
598
643
  /**
599
644
  * pubspec.yaml, read for its two dependency blocks.
600
645
  *
@@ -868,6 +913,8 @@ async function analyzeProject(projectPath) {
868
913
  goDeps: unique(goDeps),
869
914
  rustDeps: unique(rustDeps),
870
915
  runtimeRustDeps: unique(runtimeRustDeps),
916
+ elixirDeps: unique(elixirDeps),
917
+ runtimeElixirDeps: unique(runtimeElixirDeps),
871
918
  rubyDeps: unique(rubyDeps),
872
919
  dotnetDeps: unique(dotnetDeps),
873
920
  dotnetWebSdk,
@@ -46,6 +46,16 @@ export declare const JVM_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
46
46
  export declare const JVM_DATABASES: Array<[string, string[]]>;
47
47
  /** Ruby frameworks, read from the Gemfile. */
48
48
  export declare const RUBY_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
49
+ /**
50
+ * Elixir frameworks, read from mix.exs.
51
+ *
52
+ * Phoenix is the answer in nearly every case. Plug is the layer underneath it and a
53
+ * real answer on its own — an Elixir service that serves requests without Phoenix
54
+ * serves them through Plug — and Bandit and Cowboy are the servers that run it.
55
+ */
56
+ export declare const ELIXIR_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
57
+ /** Elixir data stores, read from mix.exs. */
58
+ export declare const ELIXIR_DATABASES: Array<[string, string[]]>;
49
59
  /** PHP frameworks, read from composer.json. */
50
60
  export declare const PHP_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
51
61
  /**
@@ -15,7 +15,7 @@
15
15
  * that loses users.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.LANGUAGES = exports.FRONTEND_FRAMEWORKS = exports.PYTHON_BACKEND_FRAMEWORKS = exports.PHP_BACKEND_FRAMEWORKS = exports.RUBY_BACKEND_FRAMEWORKS = exports.JVM_DATABASES = exports.JVM_BACKEND_FRAMEWORKS = exports.RUST_BACKEND_FRAMEWORKS = exports.GO_BACKEND_FRAMEWORKS = exports.NODE_BACKEND_FRAMEWORKS = void 0;
18
+ exports.LANGUAGES = exports.FRONTEND_FRAMEWORKS = exports.PYTHON_BACKEND_FRAMEWORKS = exports.PHP_BACKEND_FRAMEWORKS = exports.ELIXIR_DATABASES = exports.ELIXIR_BACKEND_FRAMEWORKS = exports.RUBY_BACKEND_FRAMEWORKS = exports.JVM_DATABASES = exports.JVM_BACKEND_FRAMEWORKS = exports.RUST_BACKEND_FRAMEWORKS = exports.GO_BACKEND_FRAMEWORKS = exports.NODE_BACKEND_FRAMEWORKS = void 0;
19
19
  exports.labelFor = labelFor;
20
20
  exports.hasLabel = hasLabel;
21
21
  exports.supportedStacks = supportedStacks;
@@ -110,6 +110,27 @@ exports.RUBY_BACKEND_FRAMEWORKS = [
110
110
  ['roda', ['roda']],
111
111
  ['grape', ['grape']],
112
112
  ];
113
+ /**
114
+ * Elixir frameworks, read from mix.exs.
115
+ *
116
+ * Phoenix is the answer in nearly every case. Plug is the layer underneath it and a
117
+ * real answer on its own — an Elixir service that serves requests without Phoenix
118
+ * serves them through Plug — and Bandit and Cowboy are the servers that run it.
119
+ */
120
+ exports.ELIXIR_BACKEND_FRAMEWORKS = [
121
+ ['phoenix', ['phoenix']],
122
+ ['plug', ['plug', 'plug_cowboy']],
123
+ ['bandit', ['bandit']],
124
+ ];
125
+ /** Elixir data stores, read from mix.exs. */
126
+ exports.ELIXIR_DATABASES = [
127
+ ['postgres', ['postgrex']],
128
+ ['mysql', ['myxql']],
129
+ ['sqlite', ['ecto_sqlite3', 'exqlite']],
130
+ ['clickhouse', ['ecto_ch', 'ch']],
131
+ ['redis', ['redix']],
132
+ ['mongodb', ['mongodb_driver']],
133
+ ];
113
134
  /** PHP frameworks, read from composer.json. */
114
135
  exports.PHP_BACKEND_FRAMEWORKS = [
115
136
  ['laravel', ['laravel/framework', 'laravel/laravel']],
@@ -220,6 +241,10 @@ const LABELS = {
220
241
  hanami: 'Hanami',
221
242
  roda: 'Roda',
222
243
  grape: 'Grape',
244
+ phoenix: 'Phoenix',
245
+ plug: 'Plug',
246
+ bandit: 'Bandit',
247
+ clickhouse: 'ClickHouse',
223
248
  laravel: 'Laravel',
224
249
  symfony: 'Symfony',
225
250
  slim: 'Slim',
@@ -346,6 +371,7 @@ function supportedStacks() {
346
371
  ...entries(exports.JVM_BACKEND_FRAMEWORKS.map(([id]) => id)),
347
372
  ...entries(exports.RUBY_BACKEND_FRAMEWORKS.map(([id]) => id)),
348
373
  { id: 'ruby', label: 'Ruby', detectedFrom: 'a Gemfile with no web framework in it' },
374
+ ...entries(exports.ELIXIR_BACKEND_FRAMEWORKS.map(([id]) => id)),
349
375
  ...entries(exports.PHP_BACKEND_FRAMEWORKS.map(([id]) => id)),
350
376
  { id: 'php', label: 'PHP', detectedFrom: 'PHP sources with no framework in composer.json' },
351
377
  { id: 'aspnet-core', label: 'ASP.NET Core', detectedFrom: 'the Microsoft.NET.Sdk.Web SDK attribute' },
@@ -81,6 +81,7 @@ async function detectAuth(ctx) {
81
81
  ...(0, detectContext_1.hasAnyRustDep)(ctx, ['oauth2', 'openidconnect']),
82
82
  ...(0, detectContext_1.hasAnyGradleDep)(ctx, ['spring-boot-starter-oauth2-client', 'com.okta.spring']),
83
83
  ...(0, detectContext_1.hasAnyDotnetDep)(ctx, ['Microsoft.AspNetCore.Authentication.OpenIdConnect', 'Microsoft.Identity.Web']),
84
+ ...(0, detectContext_1.hasAnyElixirDep)(ctx, ['ueberauth', 'assent', 'openid_connect']),
84
85
  ];
85
86
  /** And a password of its own, which is what makes a reset flow something to have. */
86
87
  const storesAPasswordItself = [
@@ -89,6 +90,7 @@ async function detectAuth(ctx) {
89
90
  ...(0, detectContext_1.hasAnyRustDep)(ctx, ['argon2', 'rust-argon2', 'bcrypt', 'scrypt', 'password-hash', 'pbkdf2']),
90
91
  ...(0, detectContext_1.hasAnyGradleDep)(ctx, ['spring-security-crypto', 'org.mindrot:jbcrypt']),
91
92
  ...(0, detectContext_1.hasAnyDotnetDep)(ctx, ['Microsoft.AspNetCore.Identity']),
93
+ ...(0, detectContext_1.hasAnyElixirDep)(ctx, ['bcrypt_elixir', 'argon2_elixir', 'pbkdf2_elixir']),
92
94
  ];
93
95
  /**
94
96
  * The packages that do the authenticating, as distinct from the words people use
@@ -120,13 +122,36 @@ async function detectAuth(ctx) {
120
122
  '@oslojs/crypto',
121
123
  '@auth/core',
122
124
  ];
125
+ /**
126
+ * The same question in Elixir, where the packages have their own names.
127
+ *
128
+ * `bcrypt_elixir` hashes the password, `guardian` and `pow` hold the session,
129
+ * `ueberauth` hands identity to a provider. plausible declares three of them and
130
+ * reported no authentication at all, because none of these words appears in any of
131
+ * the lists above.
132
+ */
133
+ const elixirAuthDeps = (0, detectContext_1.hasAnyElixirDep)(ctx, [
134
+ 'bcrypt_elixir',
135
+ 'argon2_elixir',
136
+ 'pbkdf2_elixir',
137
+ 'guardian',
138
+ 'pow',
139
+ 'ueberauth',
140
+ 'assent',
141
+ 'joken',
142
+ ]);
123
143
  const authDeps = [
124
144
  ...(0, detectContext_1.hasAnyDep)(ctx, AUTH_PACKAGES),
125
145
  ...managedAuthDeps,
126
146
  ...managedAuthPyDeps,
147
+ ...elixirAuthDeps,
127
148
  ];
128
149
  const sessionDeps = [...(0, detectContext_1.hasAnyDep)(ctx, ['express-session', 'cookie-session']), ...managedAuthDeps];
129
- const twoFaDeps = (0, detectContext_1.hasAnyDep)(ctx, ['speakeasy', 'pyotp', 'qrcode', '@simplewebauthn/server', 'otplib']);
150
+ const twoFaDeps = [
151
+ ...(0, detectContext_1.hasAnyDep)(ctx, ['speakeasy', 'pyotp', 'qrcode', '@simplewebauthn/server', 'otplib']),
152
+ /** `nimble_totp` is the Elixir one, and plausible ships it. */
153
+ ...(0, detectContext_1.hasAnyElixirDep)(ctx, ['nimble_totp']),
154
+ ];
130
155
  const routeSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
131
156
  /\/(login|register|logout|signin|signup|sign-in|sign-up)\b/i,
132
157
  /requireAuth/i,
@@ -154,6 +154,22 @@ async function detectBackend(ctx) {
154
154
  for (const dep of hits)
155
155
  evidence.push({ type: 'dependency', value: dep });
156
156
  }
157
+ /**
158
+ * Elixir, read from mix.exs.
159
+ *
160
+ * No fallback beside it, for Rust's reason rather than Go's: Elixir's standard
161
+ * library has no HTTP server, so a mix project with no Phoenix, Plug or Bandit in
162
+ * it is a library or a release tool. Phoenix is what serves the requests when
163
+ * anything does.
164
+ */
165
+ for (const [framework, deps] of catalogue_1.ELIXIR_BACKEND_FRAMEWORKS) {
166
+ const hits = (0, detectContext_1.hasAnyRuntimeElixirDep)(ctx, deps);
167
+ if (!hits.length)
168
+ continue;
169
+ frameworks.push(framework);
170
+ for (const dep of hits)
171
+ evidence.push({ type: 'dependency', value: dep });
172
+ }
157
173
  /**
158
174
  * A Gemfile is not a Ruby backend on its own.
159
175
  *
@@ -28,6 +28,10 @@ export interface DetectContext {
28
28
  goDeps: string[];
29
29
  /** Crate names from Cargo.toml, lowercase. */
30
30
  rustDeps: string[];
31
+ /** Packages named in mix.exs. */
32
+ elixirDeps: string[];
33
+ /** The ones without an `only:` option, so the ones the product ships. */
34
+ runtimeElixirDeps: string[];
31
35
  /** Of those, the ones the product ships with rather than only builds and tests with. */
32
36
  runtimeRustDeps: string[];
33
37
  /** Gem names from the Gemfile, lowercase. */
@@ -76,6 +80,9 @@ export declare function hasRuntimePyDep(ctx: DetectContext, name: string): boole
76
80
  export declare function hasAnyDep(ctx: DetectContext, names: string[]): string[];
77
81
  export declare function hasPyDep(ctx: DetectContext, name: string): boolean;
78
82
  export declare function hasAnyRustDep(ctx: DetectContext, names: string[]): string[];
83
+ export declare function hasAnyElixirDep(ctx: DetectContext, names: string[]): string[];
84
+ /** The Elixir counterpart: shipped, not merely declared. */
85
+ export declare function hasAnyRuntimeElixirDep(ctx: DetectContext, names: string[]): string[];
79
86
  /** The Rust counterpart of `hasRuntimeDep`: shipped, not merely present. */
80
87
  export declare function hasAnyRuntimeRustDep(ctx: DetectContext, names: string[]): string[];
81
88
  export declare function hasAnyPyDep(ctx: DetectContext, names: string[]): string[];
@@ -6,6 +6,8 @@ exports.hasRuntimePyDep = hasRuntimePyDep;
6
6
  exports.hasAnyDep = hasAnyDep;
7
7
  exports.hasPyDep = hasPyDep;
8
8
  exports.hasAnyRustDep = hasAnyRustDep;
9
+ exports.hasAnyElixirDep = hasAnyElixirDep;
10
+ exports.hasAnyRuntimeElixirDep = hasAnyRuntimeElixirDep;
9
11
  exports.hasAnyRuntimeRustDep = hasAnyRuntimeRustDep;
10
12
  exports.hasAnyPyDep = hasAnyPyDep;
11
13
  exports.hasPhpDep = hasPhpDep;
@@ -41,6 +43,13 @@ function hasPyDep(ctx, name) {
41
43
  function hasAnyRustDep(ctx, names) {
42
44
  return names.filter((name) => ctx.rustDeps.includes(name.toLowerCase()));
43
45
  }
46
+ function hasAnyElixirDep(ctx, names) {
47
+ return names.filter((name) => ctx.elixirDeps.includes(name.toLowerCase()));
48
+ }
49
+ /** The Elixir counterpart: shipped, not merely declared. */
50
+ function hasAnyRuntimeElixirDep(ctx, names) {
51
+ return names.filter((name) => ctx.runtimeElixirDeps.includes(name.toLowerCase()));
52
+ }
44
53
  /** The Rust counterpart of `hasRuntimeDep`: shipped, not merely present. */
45
54
  function hasAnyRuntimeRustDep(ctx, names) {
46
55
  return names.filter((name) => ctx.runtimeRustDeps.includes(name.toLowerCase()));
@@ -109,6 +109,22 @@ async function detectDatabase(ctx) {
109
109
  evidence.push({ type: 'dependency', value: h });
110
110
  }
111
111
  }
112
+ /**
113
+ * Elixir, by driver package.
114
+ *
115
+ * Ecto is the query layer and names no store: `postgrex` is what makes it Postgres,
116
+ * `ecto_ch` what makes it ClickHouse. plausible has both — its application data in
117
+ * Postgres and its analytics in ClickHouse — and was reported as having no data
118
+ * layer at all.
119
+ */
120
+ for (const [db, names] of catalogue_1.ELIXIR_DATABASES) {
121
+ const hits = (0, detectContext_1.hasAnyElixirDep)(ctx, names);
122
+ if (hits.length) {
123
+ databases.add(db);
124
+ for (const h of hits)
125
+ evidence.push({ type: 'dependency', value: h });
126
+ }
127
+ }
112
128
  const goHits = [
113
129
  ['postgres', ['jackc/pgx', 'jackc/pgx/v5', 'lib/pq']],
114
130
  ['mysql', ['go-sql-driver/mysql']],
@@ -78,7 +78,7 @@ async function detectDeployment(ctx) {
78
78
  key: 'deployment.readiness',
79
79
  present: presentFiles.length > 0 || hits.length > 0,
80
80
  complete: prodAware,
81
- evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anything that says how this is deployed', ['Dockerfile', 'docker-compose', '.github/workflows/', 'Procfile', 'fly.toml', 'render.yaml', 'NODE_ENV', 'RAILS_ENV', 'ASPNETCORE_ENVIRONMENT', 'a file naming production']),
81
+ evidence: (0, absenceEvidence_1.evidenceOrFileNameSearch)(evidence, 'anything that says how this is deployed', ['Dockerfile', 'docker-compose', '.github/workflows/', 'Procfile', 'fly.toml', 'render.yaml', 'NODE_ENV', 'RAILS_ENV', 'ASPNETCORE_ENVIRONMENT', 'a file naming production']),
82
82
  details: {
83
83
  dockerArtifacts: presentFiles.some((f) => /Dockerfile|compose/.test(f)),
84
84
  ci: presentFiles.some((f) => f.startsWith('.github/workflows/')),
@@ -38,7 +38,7 @@ async function detectDocker(ctx) {
38
38
  key: 'infra.docker',
39
39
  present: Boolean(dockerfile || composeFile),
40
40
  complete: hasHealthcheck,
41
- evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a container definition', ['Dockerfile', 'Containerfile', 'docker-compose.yml', 'compose.yaml']),
41
+ evidence: (0, absenceEvidence_1.evidenceOrFileNameSearch)(evidence, 'a container definition', ['Dockerfile', 'Containerfile', 'docker-compose.yml', 'compose.yaml']),
42
42
  details: { dockerfile: Boolean(dockerfile), compose: Boolean(composeFile), hasHealthcheck, hasExpose, services },
43
43
  };
44
44
  }
@@ -18,6 +18,7 @@ const OTHER_MANIFESTS = [
18
18
  { manager: 'go modules', pattern: /(^|\/)go\.mod$/ },
19
19
  { manager: 'cargo', pattern: /(^|\/)Cargo\.toml$/ },
20
20
  { manager: 'bundler', pattern: /(^|\/)Gemfile$/ },
21
+ { manager: 'mix', pattern: /(^|\/)mix\.exs$/ },
21
22
  { manager: 'gradle', pattern: /(^|\/)build\.gradle(\.kts)?$/ },
22
23
  { manager: 'maven', pattern: /(^|\/)pom\.xml$/ },
23
24
  { manager: 'nuget', pattern: /\.(csproj|fsproj|vbproj)$/i },
@@ -59,6 +60,7 @@ const MANIFEST_LANGUAGE = {
59
60
  'go modules': /\.go$/,
60
61
  cargo: /\.rs$/,
61
62
  bundler: /\.rb$/,
63
+ mix: /\.exs?$/,
62
64
  gradle: /\.(java|kt|kts|scala|groovy)$/,
63
65
  maven: /\.(java|kt|kts|scala|groovy)$/,
64
66
  nuget: /\.(cs|fs|vb)$/,
@@ -279,7 +279,9 @@ async function detectSecurity(ctx) {
279
279
  * that it does not throttle. The list had grown npm, Python and Rust entries and
280
280
  * skipped the ecosystem where the answer is a single well-known name.
281
281
  */
282
- (0, detectContext_1.hasAnyRubyDep)(ctx, ['rack-attack', 'rack_attack']).length > 0;
282
+ (0, detectContext_1.hasAnyRubyDep)(ctx, ['rack-attack', 'rack_attack']).length > 0 ||
283
+ /** Elixir's, which are plugs: `hammer` counts, `plug_attack` and `ex_rated` refuse. */
284
+ (0, detectContext_1.hasAnyElixirDep)(ctx, ['hammer', 'plug_attack', 'ex_rated', 'pow_ratelimit']).length > 0;
283
285
  /**
284
286
  * Throttling by what the protocol says, not by what the variable is called.
285
287
  *
@@ -10,7 +10,7 @@
10
10
  * Only managers whose manifest this analyzer actually reads are named here. A name it
11
11
  * cannot back with a parsed file would be a guess from a filename.
12
12
  */
13
- export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'pip' | 'poetry' | 'python' | 'pub' | 'composer' | 'go modules' | 'cargo' | 'bundler' | 'gradle' | 'maven' | 'nuget' | 'swift package manager' | 'cocoapods' | 'unknown';
13
+ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'pip' | 'poetry' | 'python' | 'pub' | 'composer' | 'go modules' | 'cargo' | 'bundler' | 'gradle' | 'maven' | 'nuget' | 'swift package manager' | 'cocoapods' | 'mix' | 'unknown';
14
14
  export type PackageManagerConfidence = 'lockfile' | 'manifest' | 'inferred' | 'unknown';
15
15
  export interface WorkspaceStack {
16
16
  root: string;
@@ -52,6 +52,15 @@ export interface DetectorEvidence {
52
52
  * the reader can check and disagree with.
53
53
  */
54
54
  type: 'file' | 'dependency' | 'snippet' | 'note' | 'search';
55
+ /**
56
+ * For a `search`: whether it read the source text or only the list of file names.
57
+ *
58
+ * "No Dockerfile" is true of a repository in any language, because the file list is
59
+ * readable whatever is inside the files. "No security headers" is a claim about what
60
+ * the code does, and a language this analyzer cannot read cannot answer it. Only the
61
+ * second kind is withdrawn where most of the repository went unread.
62
+ */
63
+ overFileNames?: boolean;
55
64
  value: string;
56
65
  file?: string;
57
66
  line?: number;
@@ -72,6 +72,33 @@ function onlyEvidencedInDocumentation(finding, hasProductSource) {
72
72
  const cited = finding.evidence.filter((item) => item.file);
73
73
  return cited.length > 0 && cited.every((item) => detectPackaging_1.DOCS_DIRECTORIES.test(item.file));
74
74
  }
75
+ /**
76
+ * A verdict nothing could have reached.
77
+ *
78
+ * Teaching the analyzer to read `mix.exs` gave it plausible's backend, its two data
79
+ * stores and its password hashing — all from package names nobody there invented. It
80
+ * also turned every other capability from `unknown` into `missing`: no CORS, no
81
+ * security headers, no health endpoint, no consent, each at `high`, about 1257 Elixir
82
+ * files the analyzer had not opened. The guard that used to hold those back was an
83
+ * accident — with no backend detected, the rules said nothing here serves requests —
84
+ * and naming the backend removed it.
85
+ *
86
+ * So it is said properly. Where most of a repository is a language this analyzer does
87
+ * not read, a finding whose only evidence is the search that found nothing is a
88
+ * question that went unasked, and `unknown` is the answer. A finding still holding a
89
+ * dependency, a file or a line stands: `bcrypt_elixir` in the manifest is evidence
90
+ * whatever language the rest of the repository is in, and so is a search over the list
91
+ * of file names: "no Dockerfile" is true of a repository in any language, because the
92
+ * names are readable whatever is inside the files.
93
+ *
94
+ * This is the same rule as the documentation one above, for the same reason. Blindness
95
+ * may turn a verdict into no verdict; it may never turn it into the opposite verdict.
96
+ */
97
+ function onlyEvidencedByASearchThatCouldNotRead(finding) {
98
+ if (finding.status === 'passed' || finding.status === 'unknown')
99
+ return false;
100
+ return finding.evidence.every((item) => item.type === 'search' && !item.overFileNames);
101
+ }
75
102
  function buildReport(analysis, options) {
76
103
  const observedFindings = (0, ruleEngine_1.runRules)(analysis);
77
104
  const observedScore = (0, score_1.computeScore)(observedFindings);
@@ -138,15 +165,30 @@ function buildReport(analysis, options) {
138
165
  * product and its findings are the product's.
139
166
  */
140
167
  const hasProductSource = analysis.files.source.some((file) => !detectPackaging_1.DOCS_DIRECTORIES.test(file));
168
+ /**
169
+ * Read here as well as below, because it decides what the findings may claim and not
170
+ * only whether the report carries a score.
171
+ */
172
+ const unreadableFileCount = analysis.files.unreadable.reduce((total, entry) => total + entry.files, 0);
173
+ const mostlyUnreadable = unreadableFileCount > analysis.files.source.length;
174
+ const [largestUnreadable] = [...analysis.files.unreadable].sort((left, right) => right.files - left.files);
141
175
  const findings = (0, evidenceDigest_1.withEvidenceDigest)((0, businessImpact_1.withBusinessImpact)([...observedFindings, ...expectationFindings]
142
- .map((finding) => onlyEvidencedInDocumentation(finding, hasProductSource)
176
+ .map((finding) => mostlyUnreadable && onlyEvidencedByASearchThatCouldNotRead(finding)
143
177
  ? {
144
178
  ...finding,
145
179
  status: 'unknown',
146
180
  severity: 'info',
147
- description: `${finding.description} Every line behind this is in the documentation site or a playground rather than in the product, so nothing here says the product does it.`,
181
+ description: `${finding.description} Most of this repository is written in ${largestUnreadable?.language ?? 'a language'}, which this analyzer does not read, so nothing here was in a position to answer.`,
182
+ recommendation: '',
148
183
  }
149
- : finding)
184
+ : onlyEvidencedInDocumentation(finding, hasProductSource)
185
+ ? {
186
+ ...finding,
187
+ status: 'unknown',
188
+ severity: 'info',
189
+ description: `${finding.description} Every line behind this is in the documentation site or a playground rather than in the product, so nothing here says the product does it.`,
190
+ }
191
+ : finding)
150
192
  .sort((a, b) => bySeverityPriority(a) - bySeverityPriority(b))));
151
193
  const combinedScore = expectationScore === undefined
152
194
  ? observedScore
@@ -241,8 +283,7 @@ function buildReport(analysis, options) {
241
283
  * this was written on crosses the line, and the first public repository that did was
242
284
  * the sixth one tried.
243
285
  */
244
- const unreadableFiles = analysis.files.unreadable.reduce((total, entry) => total + entry.files, 0);
245
- const mostlyUnreadable = unreadableFiles > analysis.files.source.length;
286
+ const unreadableFiles = unreadableFileCount;
246
287
  const inconclusive = nothingIdentified || tooLittleAssessed || mostlyUnreadable;
247
288
  // Each reason says which of the two it was, because they call for different things:
248
289
  // one is a repository this analyzer cannot read, the other is one there is barely
@@ -256,7 +297,7 @@ function buildReport(analysis, options) {
256
297
  }
257
298
  }
258
299
  if (mostlyUnreadable) {
259
- const [largest] = [...analysis.files.unreadable].sort((left, right) => right.files - left.files);
300
+ const largest = largestUnreadable;
260
301
  inconclusiveReasons.push(`Most of this repository is written in ${largest.language}, which this analyzer does not read: ${unreadableFiles} of its files were skipped and ${analysis.files.source.length} were read.`);
261
302
  }
262
303
  if (tooLittleAssessed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "1.5.0",
3
+ "version": "1.6.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": {