@produtype/core 1.26.1 → 1.27.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.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.detectMarketplace = detectMarketplace;
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
  * Marketplace-specific signals.
8
9
  *
@@ -21,8 +22,51 @@ const textSearch_1 = require("../utils/textSearch");
21
22
  // concept would actually be modelled, and a term only qualifies if its presence
22
23
  // genuinely implies a supply side — which rules out build vocabulary (`vendor`),
23
24
  // common verbs (`listing`), and anything that ships inside a payment SDK (`customer`).
24
- const SELLER_TERMS = [/\bseller\b/i, /\bmerchant\b/i, /\bstorefront\b/i, /\bsupplier\b/i];
25
- const BUYER_TERMS = [/\bbuyer\b/i, /\bpurchaser\b/i, /\bshopper\b/i];
25
+ /**
26
+ * A connected account is a seller, in the payment platform's own words.
27
+ *
28
+ * `seller`, `merchant`, `storefront` and `supplier` are four English words, and a
29
+ * mercato whose table is `venditori` has none of them. What it does have, if it pays
30
+ * anybody, is Stripe: `stripe.accounts.create({ type: 'express' })` creates a
31
+ * connected account, and a connected account is the supply side by definition — the
32
+ * platform is the other one. The same is true of `transfers.create` with a
33
+ * `destination`, which is money leaving the platform for somebody else's account.
34
+ *
35
+ * It is the anchor the payout capability beside this one already uses, and the reason
36
+ * an Italian marketplace was reported as having a payout but no seller.
37
+ */
38
+ const CONNECTED_ACCOUNT = [
39
+ /\baccounts\.create\s*\(/,
40
+ /\bstripe\.accounts\b/i,
41
+ /['"`]account\.updated['"`]|['"`]account\.application\./,
42
+ /\bdestination_account\b|destination:\s*\w/,
43
+ /\bconnected_?account/i,
44
+ ];
45
+ /**
46
+ * Nobody writes `seller` on its own.
47
+ *
48
+ * `\bseller\b` is a word boundary, and code is not prose: the supply side of a real
49
+ * marketplace is `onboardSeller`, `sellerId`, `db.sellers` and `seller_account`, and a
50
+ * word boundary matches none of the first three. A fixture written the way an English
51
+ * marketplace is actually written came out with zero seller signals and zero buyer
52
+ * signals — and then, since 1.27.0 reads that as "this repository's words are not
53
+ * mine", the commission and dispute findings would have been withdrawn from a
54
+ * repository that spells both sides on every line.
55
+ *
56
+ * A trailing lowercase letter is still excluded, so `sellerships` or `buyerish` do not
57
+ * qualify; `reseller` does, and a reseller is a supply side.
58
+ *
59
+ * The lookahead is why these are not written with the `i` flag. `/buyers?(?![a-z])/i`
60
+ * applies the flag to the lookahead too, so `[a-z]` matches the `I` of `buyerId` and
61
+ * the pattern rejects the one spelling it was widened to accept — a case-insensitive
62
+ * negative lookahead asserts the opposite of what it reads like.
63
+ */
64
+ function identifierWord(word) {
65
+ const initial = word[0];
66
+ return new RegExp(`(?:[${initial.toLowerCase()}${initial.toUpperCase()}]${word.slice(1)}|${word.toUpperCase()})s?(?![a-z])`);
67
+ }
68
+ const SELLER_TERMS = ['seller', 'merchant', 'storefront', 'supplier'].map(identifierWord);
69
+ const BUYER_TERMS = ['buyer', 'purchaser', 'shopper'].map(identifierWord);
26
70
  /** Files where a domain concept is declared rather than merely mentioned. */
27
71
  const DOMAIN_FILE = /(model|schema|entity|migration|prisma|domain|route|controller)/i;
28
72
  function domainFiles(ctx) {
@@ -33,7 +77,20 @@ async function detectMultiRole(ctx) {
33
77
  const scope = domainFiles(ctx);
34
78
  const sellerHits = await (0, textSearch_1.searchInFiles)(ctx.root, scope, SELLER_TERMS, 20);
35
79
  const buyerHits = await (0, textSearch_1.searchInFiles)(ctx.root, scope, BUYER_TERMS, 20);
36
- for (const hit of [...sellerHits, ...buyerHits]) {
80
+ /**
81
+ * The connected-account search is not scoped and not restricted to files whose name
82
+ * is an English word, because it is not searching for a word.
83
+ *
84
+ * `DOMAIN_FILE` keeps the vocabulary terms away from `vite.config.js` — a reasonable
85
+ * precaution for `vendor` and `listing`, and useless for a repository whose files
86
+ * are called `venditori.js` and `ordini.js`. It is the same filter twice over: an
87
+ * English word inside a file whose path is an English word.
88
+ *
89
+ * `stripe.accounts.create(` is neither. Nothing calls it by accident, so it can be
90
+ * looked for where the code actually is.
91
+ */
92
+ const connectedHits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, CONNECTED_ACCOUNT, 20);
93
+ for (const hit of [...sellerHits, ...buyerHits, ...connectedHits]) {
37
94
  evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
38
95
  }
39
96
  /**
@@ -63,14 +120,36 @@ async function detectMultiRole(ctx) {
63
120
  // A file name on its own is not a role either. Twenty had four such names and no
64
121
  // buyer or seller vocabulary anywhere in its code, and came out a marketplace:
65
122
  // naming a file is cheaper than building a two-sided product.
66
- const oneSide = vocabularyFiles.size >= 2;
123
+ //
124
+ // The two-file rule guards against one sentence naming both sides — a comment in
125
+ // documenso listing "Tenant", "Landlord", "Buyer", "Seller" as example labels. A
126
+ // connected account is not a sentence, so it does not need the guard: one call to
127
+ // `accounts.create` is a supply side whether or not the repository also spells one.
128
+ const oneSide = vocabularyFiles.size >= 2 || connectedHits.length > 0;
67
129
  return {
68
130
  key: 'marketplace.multiRole',
69
131
  present: oneSide,
70
132
  complete: bothSides,
71
133
  evidence,
72
134
  details: {
73
- sellerSignals: sellerHits.length,
135
+ sellerSignals: sellerHits.length + connectedHits.length,
136
+ connectedAccountSignals: connectedHits.length,
137
+ /**
138
+ * This repository runs a marketplace in words this tool does not have.
139
+ *
140
+ * The payment platform named the supply side and the repository never did: a
141
+ * connected account is created, and `seller`, `merchant`, `buyer` and
142
+ * `shopper` appear nowhere. That combination is not a marketplace missing its
143
+ * vocabulary — it is a marketplace whose vocabulary is `venditori` and
144
+ * `acquirenti`, or any of the other several thousand languages this tool reads
145
+ * none of.
146
+ *
147
+ * Commission and dispute are searched for in English and in provider API names.
148
+ * Where the provider name is absent — a cut computed in arithmetic, a table
149
+ * called `contestazioni` — the search has nothing left to look with, and the
150
+ * finding downstream becomes "not assessed" rather than "not there".
151
+ */
152
+ vocabularyUnread: connectedHits.length > 0 && sellerHits.length === 0 && buyerHits.length === 0,
74
153
  buyerSignals: buyerHits.length,
75
154
  roleFiles: roleFiles.length,
76
155
  },
@@ -95,7 +174,17 @@ async function detectPayout(ctx) {
95
174
  },
96
175
  };
97
176
  }
98
- async function detectCommission(ctx) {
177
+ /**
178
+ * Nothing was found, and the search had nothing left to look with.
179
+ *
180
+ * Commission and dispute are searched for in English and in provider API names. Where
181
+ * the provider name is absent — a cut computed as arithmetic, a table called
182
+ * `contestazioni` — only the English half remains, and a repository that never spelled
183
+ * `seller` or `buyer` was never going to answer it. `unanswered` turns the finding into
184
+ * "not assessed" downstream, which is the one direction blindness is allowed to move a
185
+ * verdict.
186
+ */
187
+ async function detectCommission(ctx, vocabularyUnread) {
99
188
  const evidence = [];
100
189
  const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
101
190
  // "Commission" is also an institution. The privacy policy of an open-source CRM
@@ -114,11 +203,12 @@ async function detectCommission(ctx) {
114
203
  return {
115
204
  key: 'marketplace.commission',
116
205
  present: hits.length > 0,
117
- evidence,
206
+ unanswered: hits.length === 0 && vocabularyUnread,
207
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a cut taken on the way through', ['commission_rate', 'commissionRate', 'application_fee', 'platform_fee', 'take_rate', 'service_fee']),
118
208
  details: { commissionSignals: hits.length },
119
209
  };
120
210
  }
121
- async function detectDispute(ctx) {
211
+ async function detectDispute(ctx, vocabularyUnread) {
122
212
  const evidence = [];
123
213
  // `refund` alone is not evidence of dispute handling — it appears in any payment
124
214
  // integration. `arbitration` was dropped after it matched a glossary entry in a
@@ -137,8 +227,9 @@ async function detectDispute(ctx) {
137
227
  return {
138
228
  key: 'marketplace.dispute',
139
229
  present: strong || weakHits.length > 0,
230
+ unanswered: !strong && weakHits.length === 0 && vocabularyUnread,
140
231
  complete: strong,
141
- evidence,
232
+ evidence: (0, absenceEvidence_1.evidenceOrSearch)(evidence, 'a way to unwind a transaction', ['disputes.create', 'chargeback', 'escrow', 'refund', 'dispute']),
142
233
  details: {
143
234
  disputeSignals: strongHits.length,
144
235
  refundOnlySignals: weakHits.length,
@@ -147,10 +238,12 @@ async function detectDispute(ctx) {
147
238
  };
148
239
  }
149
240
  async function detectMarketplace(ctx) {
241
+ const multiRole = await detectMultiRole(ctx);
242
+ const vocabularyUnread = multiRole.details?.vocabularyUnread === true;
150
243
  return Promise.all([
151
- detectMultiRole(ctx),
244
+ Promise.resolve(multiRole),
152
245
  detectPayout(ctx),
153
- detectCommission(ctx),
154
- detectDispute(ctx),
246
+ detectCommission(ctx, vocabularyUnread),
247
+ detectDispute(ctx, vocabularyUnread),
155
248
  ]);
156
249
  }
@@ -85,8 +85,14 @@ export interface ReportDiagnostics {
85
85
  * Withdrawing the claim silently would be its own failure: the reader of a project
86
86
  * whose routes are `/accedi` and `/recupero-password` would see four questions
87
87
  * simply absent, with nothing saying why. This is what the report says instead.
88
+ *
89
+ * Optional because this interface is a published type and somebody else builds
90
+ * values of it. Adding a required field to it is a breaking change however small
91
+ * the field is, and 1.26.1 shipped one in a patch release: produtype.dev stopped
92
+ * compiling on the fixture it builds for its own tests. `buildReport` always sets
93
+ * it; the question mark is for everyone who does not.
88
94
  */
89
- routeNamesUnread: boolean;
95
+ routeNamesUnread?: boolean;
90
96
  /**
91
97
  * Whether the optional TypeScript compiler was loaded for this reading.
92
98
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "1.26.1",
3
+ "version": "1.27.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": {