@produtype/core 0.41.0 → 0.43.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.
@@ -13,3 +13,16 @@ import type { DetectorEvidence } from './types';
13
13
  * would not. That is the difference between a claim and an assertion.
14
14
  */
15
15
  export declare function searchedFor(what: string, terms: string[], claim?: string): DetectorEvidence[];
16
+ /**
17
+ * The evidence, or the search that came up empty — never neither.
18
+ *
19
+ * Measured across eighty-three repositories: 768 of 2101 findings carried "no direct
20
+ * evidence captured", and 180 of those were `high`. A reader is told at high severity
21
+ * that their product has no error reporting, no roles, no tenant isolation, and the
22
+ * only thing under it is a sentence that reads like an admission of not having looked.
23
+ *
24
+ * Every detector already knows what it searched for. This is the one line that makes
25
+ * it say so, so that a reader whose roles are called `capabilities` can see why they
26
+ * were missed and tell us we are wrong.
27
+ */
28
+ export declare function evidenceOrSearch(evidence: DetectorEvidence[], what: string, terms: string[], claim?: string): DetectorEvidence[];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.searchedFor = searchedFor;
4
+ exports.evidenceOrSearch = evidenceOrSearch;
4
5
  /**
5
6
  * What was looked for, when nothing was found.
6
7
  *
@@ -23,3 +24,18 @@ function searchedFor(what, terms, claim) {
23
24
  ...(claim ? { claim } : {}),
24
25
  }];
25
26
  }
27
+ /**
28
+ * The evidence, or the search that came up empty — never neither.
29
+ *
30
+ * Measured across eighty-three repositories: 768 of 2101 findings carried "no direct
31
+ * evidence captured", and 180 of those were `high`. A reader is told at high severity
32
+ * that their product has no error reporting, no roles, no tenant isolation, and the
33
+ * only thing under it is a sentence that reads like an admission of not having looked.
34
+ *
35
+ * Every detector already knows what it searched for. This is the one line that makes
36
+ * it say so, so that a reader whose roles are called `capabilities` can see why they
37
+ * were missed and tell us we are wrong.
38
+ */
39
+ function evidenceOrSearch(evidence, what, terms, claim) {
40
+ return evidence.length > 0 ? evidence : searchedFor(what, terms, claim);
41
+ }
@@ -5,6 +5,7 @@ const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
6
  const roleChecks_1 = require("./structural/roleChecks");
7
7
  const absenceEvidence_1 = require("./absenceEvidence");
8
+ const fileNames_1 = require("./fileNames");
8
9
  /**
9
10
  * In a product that talks to a model, `role` usually means who is speaking.
10
11
  *
@@ -137,6 +138,11 @@ async function detectAuth(ctx) {
137
138
  /hashed?[_-]?(api[_-]?)?key/i,
138
139
  /token\s*scope/i,
139
140
  ], 20);
141
+ const passwordResetFiles = (0, fileNames_1.searchFileNames)(sourceFiles, [
142
+ /(password|pwd)[_-]?(reset|recovery)/i,
143
+ /(reset|recover)[_-]?password/i,
144
+ /forgot[_-]?password/i,
145
+ ]);
140
146
  const passwordResetSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
141
147
  /forgot\s*password/i,
142
148
  /password[_-]?reset/i,
@@ -154,6 +160,17 @@ async function detectAuth(ctx) {
154
160
  // The same shape in other frameworks that hand you the flow rather than the words.
155
161
  /Devise|devise_for/,
156
162
  /Auth::routes\(/,
163
+ /**
164
+ * The same flow, called recovery.
165
+ *
166
+ * supabase/auth is a product whose entire purpose is authentication, and it was
167
+ * reported as having no password reset. Its file says "Password recovery
168
+ * requires an email" and names the type `Recovery`: the words "reset" and
169
+ * "forgot" appear nowhere, because it says the thing differently.
170
+ */
171
+ /password\s*recover(y|ing)?/i,
172
+ /recover(y)?[_-]?password/i,
173
+ /passwordRecovery/,
157
174
  ], 20);
158
175
  const emailVerificationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/verify\s*email/i, /email[_-]?verification/i, /confirm\s*email/i, /isEmailVerified/i], 20);
159
176
  const sessionSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/session/i, /cookie/i, /jwt/i, /refresh\s*token/i, /httpOnly/i], 25);
@@ -295,7 +312,7 @@ async function detectAuth(ctx) {
295
312
  key: 'auth.core',
296
313
  present: hasAuth,
297
314
  complete: hasAuth && hasAuthz,
298
- evidence: [...depEvidence(authDeps), ...snippetEvidence(routeSignals)],
315
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)([...depEvidence(authDeps), ...snippetEvidence(routeSignals)], 'a way for somebody to sign in', ['next-auth', 'passport', 'lucia', '@clerk/', '@supabase/auth', 'django.contrib.auth', 'devise', 'jsonwebtoken', 'a /login or /signin route', 'signIn(', 'authenticate(']),
299
316
  details: {
300
317
  hasAuth,
301
318
  hasAuthz,
@@ -309,7 +326,8 @@ async function detectAuth(ctx) {
309
326
  key: 'auth.externalIdentityOnly',
310
327
  present: managedAuthDeps.length > 0 &&
311
328
  (0, detectContext_1.hasAnyDep)(ctx, ['bcrypt', 'bcryptjs', 'argon2', 'scrypt-kdf', 'passport-local']).length === 0 &&
312
- passwordResetSignals.length === 0,
329
+ passwordResetSignals.length === 0 &&
330
+ passwordResetFiles.length === 0,
313
331
  evidence: depEvidence(managedAuthDeps),
314
332
  details: { managedProviders: managedAuthDeps.length },
315
333
  },
@@ -321,17 +339,17 @@ async function detectAuth(ctx) {
321
339
  {
322
340
  key: 'auth.apiKeys',
323
341
  present: apiKeySignals.length > 0,
324
- evidence: snippetEvidence(apiKeySignals),
342
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(apiKeySignals), 'keys this product issues and checks', ['x-api-key', 'api_keys.find', 'api_keys.where', 'hashedApiKey', 'token scope']),
325
343
  },
326
344
  {
327
345
  key: 'auth.passwordReset',
328
- present: passwordResetSignals.length > 0,
346
+ present: passwordResetSignals.length > 0 || passwordResetFiles.length > 0,
329
347
  // The terms, where nothing matched. "No direct evidence captured" reads like
330
348
  // "we did not look"; this lets a reader whose flow is called `recoverAccess`
331
349
  // see in one line why it was missed.
332
- evidence: passwordResetSignals.length > 0
333
- ? snippetEvidence(passwordResetSignals)
334
- : (0, absenceEvidence_1.searchedFor)('a password reset flow', ['"forgot password"', 'password_reset', 'password-reset', '"reset token"', 'django.contrib.auth.urls', 'PasswordResetView', 'devise_for', 'Auth::routes(']),
350
+ evidence: passwordResetSignals.length > 0 || passwordResetFiles.length > 0
351
+ ? [...snippetEvidence(passwordResetSignals), ...(0, fileNames_1.fileNameEvidence)(passwordResetFiles)]
352
+ : (0, absenceEvidence_1.searchedFor)('a password reset flow', ['"forgot password"', 'password_reset', 'password-reset', '"reset token"', '"password recovery"', 'django.contrib.auth.urls', 'PasswordResetView', 'devise_for', 'Auth::routes(', 'a file named for password reset or recovery']),
335
353
  },
336
354
  {
337
355
  key: 'auth.emailVerification',
@@ -348,22 +366,22 @@ async function detectAuth(ctx) {
348
366
  {
349
367
  key: 'authz.roles',
350
368
  present: roleSignals.length > 0,
351
- evidence: snippetEvidence(roleSignals),
369
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(roleSignals), 'role checks', ['role ===', 'hasRole', 'isAdmin', 'user.role', 'roles.includes', '@Roles', 'role_required']),
352
370
  },
353
371
  {
354
372
  key: 'authz.permissions',
355
373
  present: permissionSignals.length > 0,
356
- evidence: snippetEvidence(permissionSignals),
374
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(permissionSignals), 'permission checks', ['requirePermission', 'permission_classes', 'permissions.py', 'authorize(', 'can(']),
357
375
  },
358
376
  {
359
377
  key: 'authz.resourceLevel',
360
378
  present: resourceLevelSignals.length > 0 || permissionSignals.length > 0,
361
- evidence: snippetEvidence(resourceLevelSignals),
379
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(resourceLevelSignals), 'a check that the row belongs to the caller', ['requirePermission', 'permission_classes', 'authorize(', 'canAccess(', 'hasAccessTo(', 'ownerId', 'createdBy', 'req.user.id', 'userId ===']),
362
380
  },
363
381
  {
364
382
  key: 'tenancy.organization',
365
383
  present: hasOrganization,
366
- evidence: snippetEvidence(organizationSignals),
384
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(organizationSignals), 'a tenant of its own', ['organizationId', 'organization_id', 'tenantId', 'tenant_id', 'workspaceId', 'accountId']),
367
385
  details: {
368
386
  b2bHint,
369
387
  missingTenantRisk: b2bHint && !hasOrganization,
@@ -372,7 +390,7 @@ async function detectAuth(ctx) {
372
390
  {
373
391
  key: 'tenancy.membership',
374
392
  present: membershipSignals.length > 0,
375
- evidence: snippetEvidence(membershipSignals),
393
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(snippetEvidence(membershipSignals), 'a membership joining a person to a tenant', ['membership', 'organizationMember', 'teamMember', 'workspaceMember', 'memberId with a tenant word beside it']),
376
394
  },
377
395
  ];
378
396
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectBilling = detectBilling;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
+ const absenceEvidence_1 = require("./absenceEvidence");
6
7
  function toEvidence(matches) {
7
8
  return matches.map((m) => ({ type: 'snippet', value: m.snippet, file: m.file, line: m.line }));
8
9
  }
@@ -120,7 +121,7 @@ async function detectBilling(ctx) {
120
121
  {
121
122
  key: 'billing.stripe',
122
123
  present: hasStrongStripeSignal,
123
- evidence,
124
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a payment integration', ['stripe', '@stripe/stripe-js', 'checkout.sessions.create', 'paddle', 'lemonsqueezy', 'braintree', 'a route under /webhook/stripe']),
124
125
  details: {
125
126
  stripe: hasStrongStripeSignal,
126
127
  },
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectDeployment = detectDeployment;
4
4
  const textSearch_1 = require("../utils/textSearch");
5
+ const absenceEvidence_1 = require("./absenceEvidence");
5
6
  async function detectDeployment(ctx) {
6
7
  const evidence = [];
7
8
  /**
@@ -77,7 +78,7 @@ async function detectDeployment(ctx) {
77
78
  key: 'deployment.readiness',
78
79
  present: presentFiles.length > 0 || hits.length > 0,
79
80
  complete: prodAware,
80
- evidence,
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
82
  details: {
82
83
  dockerArtifacts: presentFiles.some((f) => /Dockerfile|compose/.test(f)),
83
84
  ci: presentFiles.some((f) => f.startsWith('.github/workflows/')),
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectErrorReporting = detectErrorReporting;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
+ const absenceEvidence_1 = require("./absenceEvidence");
6
7
  /**
7
8
  * Whether a crash reaches the people who can fix it.
8
9
  *
@@ -53,7 +54,7 @@ async function detectErrorReporting(ctx) {
53
54
  // A reporting service is wired up once and covers everything; a hand-rolled handler
54
55
  // usually covers what its author remembered.
55
56
  complete: deps.length > 0,
56
- evidence,
57
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'somewhere a crash in the browser is sent', ['@sentry/browser', '@sentry/react', '@bugsnag/js', 'rollbar', 'logrocket', '@datadog/browser-rum', 'sentry-sdk', 'window.onerror', 'addEventListener("error")', 'addEventListener("unhandledrejection")']),
57
58
  details: { services: deps, handlers: handlers.length },
58
59
  };
59
60
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectGame = detectGame;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
+ const absenceEvidence_1 = require("./absenceEvidence");
6
7
  /**
7
8
  * Whether this project is a game.
8
9
  *
@@ -162,7 +163,7 @@ async function detectStatePersistence(ctx) {
162
163
  present: saves || durable,
163
164
  // Client storage alone is the partial case: it saves, until it does not.
164
165
  complete: durable,
165
- evidence,
166
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anywhere progress is written down', ['localStorage', 'sessionStorage', 'IndexedDB', 'a save route', 'schema.prisma', 'a .sql file', 'pg', 'mongoose', 'better-sqlite3']),
166
167
  details: { durable, clientStorage: clientOnly.length > 0, saveRoutines: saveRoutes.length },
167
168
  };
168
169
  }
@@ -193,7 +194,7 @@ async function detectAssetDelivery(ctx) {
193
194
  return {
194
195
  key: 'app.assetDelivery',
195
196
  present: hits.length > 0 || headerFiles.length > 0,
196
- evidence,
197
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anything that lets a browser keep the assets', ['express.static(..., maxAge)', 'Cache-Control: max-age', 'setHeaders:', 'assetPrefix:', 'immutable', '_headers', 'netlify.toml', 'vercel.json']),
197
198
  details: { inCode: hits.length, headerFiles: headerFiles.length },
198
199
  };
199
200
  }
@@ -3,11 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectGdpr = detectGdpr;
4
4
  const textSearch_1 = require("../utils/textSearch");
5
5
  const absenceEvidence_1 = require("./absenceEvidence");
6
+ const fileNames_1 = require("./fileNames");
6
7
  function toEvidence(matches) {
7
8
  return matches.map((m) => ({ type: 'snippet', value: m.snippet, file: m.file, line: m.line }));
8
9
  }
9
10
  async function detectGdpr(ctx) {
10
11
  const consent = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/cookie[-_ ]?consent/i, /consentGiven/i, /privacyConsent/i, /gdprConsent/i], 20);
12
+ /**
13
+ * The same duty, in the words each ecosystem actually uses.
14
+ *
15
+ * The list was written in one dialect. Article 20 is `exportUserData` in a Node
16
+ * application and `UserExport` in a Rails one; article 17 is `deleteAccount` here
17
+ * and `UserAnonymizer` there. Discourse ships both and was told it had neither,
18
+ * at `high` — which is the severity a reader acts on.
19
+ */
11
20
  const exportRoute = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
12
21
  /\/gdpr\/export\b/i,
13
22
  /exportUserData/i,
@@ -16,8 +25,40 @@ async function detectGdpr(ctx) {
16
25
  /rightToAccess/i,
17
26
  /data portability/i,
18
27
  /export personal data/i,
28
+ /user[_-]?export/i,
29
+ /export[_-]?(user|account|profile)\b/i,
30
+ /download (your|my) data/i,
31
+ ], 20);
32
+ const exportFiles = (0, fileNames_1.searchFileNames)(ctx.files.source, [
33
+ /user[_-]?export/i,
34
+ /(data|account|profile)[_-]?export/i,
35
+ /export[_-]?(user|account|personal)/i,
36
+ ]);
37
+ const erasure = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
38
+ /erasure/i,
39
+ /delete account/i,
40
+ /right to be forgotten/i,
41
+ /delete user data/i,
42
+ /delete personal data/i,
43
+ /**
44
+ * Anonymisation, which is how a forum satisfies article 17 without losing the
45
+ * thread. Discourse's is `UserAnonymizer` and it was told it had no erasure.
46
+ */
47
+ /anonymi[sz]e[_-]?(user|account)/i,
48
+ /user[_-]?anonymi[sz]/i,
19
49
  ], 20);
20
- const erasure = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/erasure/i, /delete account/i, /right to be forgotten/i, /delete user data/i, /delete personal data/i], 20);
50
+ /**
51
+ * `deleteUser(id)` is not article 17 — it is every admin screen ever written, and
52
+ * as a line it matched a teaching exercise about `git log -S "deleteUser"` and a
53
+ * function that removes a cloud provider account. A file *named* for deleting
54
+ * accounts is a different thing: somebody built a feature and called it that.
55
+ */
56
+ const erasureFiles = (0, fileNames_1.searchFileNames)(ctx.files.source, [
57
+ /user[_-]?anonymi[sz]/i,
58
+ /anonymi[sz]er/i,
59
+ /(account|user)[_-]?deletion/i,
60
+ /(delete|erase)[_-]?(account|my[_-]?data)/i,
61
+ ]);
21
62
  const retention = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
22
63
  /(gdpr|privacy).*(retention|purge|delete)/i,
23
64
  /(retention|purge|delete).*(personal data|user data|data subject)/i,
@@ -44,13 +85,17 @@ async function detectGdpr(ctx) {
44
85
  },
45
86
  {
46
87
  key: 'gdpr.export.route',
47
- present: exportRoute.length > 0,
48
- evidence: evidenceOr(exportRoute, 'export'),
88
+ present: exportRoute.length > 0 || exportFiles.length > 0,
89
+ evidence: exportRoute.length > 0 || exportFiles.length > 0
90
+ ? [...toEvidence(exportRoute), ...(0, fileNames_1.fileNameEvidence)(exportFiles)]
91
+ : evidenceOr(exportRoute, 'export'),
49
92
  },
50
93
  {
51
94
  key: 'gdpr.erasure.route',
52
- present: erasure.length > 0,
53
- evidence: evidenceOr(erasure, 'erasure'),
95
+ present: erasure.length > 0 || erasureFiles.length > 0,
96
+ evidence: erasure.length > 0 || erasureFiles.length > 0
97
+ ? [...toEvidence(erasure), ...(0, fileNames_1.fileNameEvidence)(erasureFiles)]
98
+ : evidenceOr(erasure, 'erasure'),
54
99
  },
55
100
  {
56
101
  key: 'gdpr.retention.job',
@@ -4,6 +4,7 @@ exports.detectMobile = detectMobile;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const readTextFileSafe_1 = require("../utils/readTextFileSafe");
6
6
  const textSearch_1 = require("../utils/textSearch");
7
+ const absenceEvidence_1 = require("./absenceEvidence");
7
8
  /**
8
9
  * Whether this is an application that ships to somebody else's phone, and what that
9
10
  * demands of it.
@@ -351,10 +352,18 @@ async function detectMobile(ctx) {
351
352
  // Declared-and-explained, or asked at the moment of use. A manifest full of
352
353
  // permissions with nothing next to them is the failing case, not the passing one.
353
354
  present: permissionsExplained,
354
- evidence: permissionEvidence,
355
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(permissionEvidence, 'a reason given for each permission asked for', ['NSCameraUsageDescription and the other NS*UsageDescription keys', 'a permission rationale beside uses-permission', 'requestPermissions(', 'Permission.request']),
356
+ },
357
+ {
358
+ key: 'mobile.credentialStorage',
359
+ present: credentialEvidence.length > 0,
360
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(credentialEvidence, 'a secure store for credentials on the device', ['react-native-keychain', 'expo-secure-store', 'flutter_secure_storage', 'KeychainAccess', 'kSecClass', 'EncryptedSharedPreferences', 'AndroidKeyStore', 'SecItemAdd']),
361
+ },
362
+ {
363
+ key: 'mobile.offline',
364
+ present: offlineDeps.length > 0,
365
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(offlineEvidence, 'a local database the app can read with no network', ['sqflite', 'drift', 'hive', 'isar', 'objectbox', 'realm', 'androidx.room', 'sqldelight', 'grdb.swift', 'sqlite.swift']),
355
366
  },
356
- { key: 'mobile.credentialStorage', present: credentialEvidence.length > 0, evidence: credentialEvidence },
357
- { key: 'mobile.offline', present: offlineDeps.length > 0, evidence: offlineEvidence },
358
367
  {
359
368
  key: 'mobile.forcedUpdate',
360
369
  present: forcedUpdate.length > 0,
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectEngagement = detectEngagement;
4
4
  const detectContext_1 = require("./detectContext");
5
5
  const textSearch_1 = require("../utils/textSearch");
6
+ const absenceEvidence_1 = require("./absenceEvidence");
6
7
  /**
7
8
  * Signals for the two things a consumer product needs that a backend service does not:
8
9
  * a way to bring a stranger from signup to first use, and a way to reach them
@@ -50,7 +51,7 @@ async function detectNotifications(ctx) {
50
51
  key: 'notifications.transactional',
51
52
  present: capable || wired,
52
53
  complete: capable && wired,
53
- evidence,
54
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a way to tell a user something happened', ['nodemailer', 'resend', '@sendgrid/mail', 'postmark', 'mailgun', 'django.core.mail', 'sendMail(', 'send_mail(', 'firebase-admin messaging', 'an email template file']),
54
55
  details: {
55
56
  emailDependency: emailDeps.length > 0,
56
57
  pushDependency: pushDeps.length > 0,
@@ -72,7 +73,7 @@ async function detectOnboarding(ctx) {
72
73
  key: 'onboarding.flow',
73
74
  present: strong || hits.length > 0,
74
75
  complete: strong,
75
- evidence,
76
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'anything that takes a new user through a first run', ['onboarding', 'getting-started', 'welcome', 'firstRun', 'setup wizard', 'a file named for onboarding']),
76
77
  details: { onboardingFiles: files.length, onboardingSignals: hits.length },
77
78
  };
78
79
  }
@@ -5,6 +5,7 @@ exports.detectPackaging = detectPackaging;
5
5
  const readTextFileSafe_1 = require("../utils/readTextFileSafe");
6
6
  const detectContext_1 = require("./detectContext");
7
7
  const readTextFileSafe_2 = require("../utils/readTextFileSafe");
8
+ const absenceEvidence_1 = require("./absenceEvidence");
8
9
  /**
9
10
  * Whether a project is fit to be installed and depended on by someone else.
10
11
  *
@@ -212,7 +213,7 @@ async function detectPackaging(ctx) {
212
213
  // answer, and a reader who has one does not need the other to use the code.
213
214
  present: licenseFiles.length > 0 || declaredLicense,
214
215
  complete: licenseFiles.length > 0 && declaredLicense,
215
- evidence: licenseEvidence,
216
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(licenseEvidence, 'a licence', ['LICENSE', 'LICENCE', 'COPYING', 'a license field in package.json', 'a license classifier in pyproject.toml']),
216
217
  details: { files: licenseFiles.length, declared: declaredLicense },
217
218
  },
218
219
  {
@@ -234,18 +235,18 @@ async function detectPackaging(ctx) {
234
235
  key: 'docs.readme',
235
236
  present: readmeFiles.length > 0,
236
237
  complete: readmeLength > 400,
237
- evidence: readmeFiles.map((file) => ({ type: 'file', value: `${file} (${readmeLength} characters)`, file })),
238
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(readmeFiles.map((file) => ({ type: 'file', value: `${file} (${readmeLength} characters)`, file })), 'a readme', ['README', 'README.md', 'README.rst', 'readme.txt']),
238
239
  details: { length: readmeLength },
239
240
  },
240
241
  {
241
242
  key: 'quality.tests',
242
243
  present: testFiles.length > 0,
243
244
  complete: testFiles.length > 0 && (hasTestScript || hasPyTestRunner),
244
- evidence: [
245
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)([
245
246
  ...testFiles.slice(0, 3).map((file) => ({ type: 'file', value: file, file })),
246
247
  ...(hasTestScript ? [{ type: 'note', value: 'an "npm test" script' }] : []),
247
248
  ...(hasPyTestRunner ? [{ type: 'note', value: 'a Python test runner' }] : []),
248
- ],
249
+ ], 'tests', ['a path under test/ or tests/', '*.test.*', '*.spec.*', 'test_*.py', '*_test.go', 'an "npm test" script', 'pytest', 'tox']),
249
250
  details: { files: testFiles.length, runner: hasTestScript || hasPyTestRunner },
250
251
  },
251
252
  {
@@ -0,0 +1,33 @@
1
+ import type { DetectorEvidence } from './types';
2
+ /**
3
+ * What a file is called, as evidence about what the product does.
4
+ *
5
+ * This analyzer reads the inside of files and never the names on them. Discourse was
6
+ * told it offers no way to export or erase personal data while shipping
7
+ * `app/models/user_export.rb` and `app/services/user_anonymizer.rb`; supabase/auth was
8
+ * told it has no password reset while shipping `internal/api/recover.go`. In each case
9
+ * the concept is named most clearly in the one place nothing was looking.
10
+ *
11
+ * It is weaker evidence than a line and it is honest about that: the citation is the
12
+ * file, with no line number, because a file name is not a line. It is also
13
+ * language-neutral in a way no list of words can be — `user_export.rb`,
14
+ * `UserExportService.java` and `export_user.py` are one pattern.
15
+ *
16
+ * The limit is low on purpose: a Django project has ten templates named for the
17
+ * password reset and the reader needs to see two, not ten.
18
+ *
19
+ * Matched on the path with its extension removed, so a pattern never has to know
20
+ * whether it is looking at Ruby or Go.
21
+ */
22
+ export interface FileNameMatch {
23
+ file: string;
24
+ }
25
+ export declare function searchFileNames(files: string[], patterns: RegExp[], limit?: number): FileNameMatch[];
26
+ /**
27
+ * The evidence line for a file name.
28
+ *
29
+ * `type: 'file'` and no `line`, which is what distinguishes it from a snippet: the
30
+ * reader is being pointed at a file to open, not at a line to read. The alternative —
31
+ * claiming line 1 — is the mistake this analyzer spent a release removing.
32
+ */
33
+ export declare function fileNameEvidence(matches: FileNameMatch[], claim?: string): DetectorEvidence[];
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.searchFileNames = searchFileNames;
4
+ exports.fileNameEvidence = fileNameEvidence;
5
+ function searchFileNames(files, patterns, limit = 4) {
6
+ const matches = [];
7
+ for (const file of files) {
8
+ if (matches.length >= limit)
9
+ break;
10
+ const withoutExtension = file.replace(/\.[A-Za-z0-9]+$/, '');
11
+ if (patterns.some((pattern) => pattern.test(withoutExtension))) {
12
+ matches.push({ file });
13
+ }
14
+ }
15
+ return matches;
16
+ }
17
+ /**
18
+ * The evidence line for a file name.
19
+ *
20
+ * `type: 'file'` and no `line`, which is what distinguishes it from a snippet: the
21
+ * reader is being pointed at a file to open, not at a line to read. The alternative —
22
+ * claiming line 1 — is the mistake this analyzer spent a release removing.
23
+ */
24
+ function fileNameEvidence(matches, claim) {
25
+ return matches.map((m) => ({
26
+ type: 'file',
27
+ value: `a file named ${m.file}`,
28
+ file: m.file,
29
+ ...(claim ? { claim } : {}),
30
+ }));
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.41.0",
3
+ "version": "0.43.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": {