@produtype/core 0.7.0 → 0.8.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.
@@ -135,7 +135,10 @@ function parsePyproject(text) {
135
135
  return deps;
136
136
  }
137
137
  function isTestOrExamplePath(file) {
138
- return /(^|\/)(__tests__|tests?|test-data|fixtures|frontend-example)(\/|$)/i.test(file)
138
+ // `__mocks__` was missing, and a mock is the most misleading file in a repository:
139
+ // `application_fee_percent: null` inside a Stripe fixture made an open-source CRM read
140
+ // as a marketplace taking a cut. A field set to null is evidence of absence.
141
+ return /(^|\/)(__tests__|__mocks__|mocks?|tests?|test-data|fixtures|frontend-example)(\/|$)/i.test(file)
139
142
  || /(^|\/)test[-_][^/]+\.(ts|tsx|js|jsx|mjs|cjs|py)$/i.test(file)
140
143
  || /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py)$/i.test(file);
141
144
  }
@@ -36,15 +36,34 @@ async function detectMultiRole(ctx) {
36
36
  for (const hit of [...sellerHits, ...buyerHits]) {
37
37
  evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
38
38
  }
39
- const roleFiles = ctx.files.all
40
- .filter((file) => /(seller|merchant|storefront)/i.test(file))
41
- .slice(0, 20);
39
+ /**
40
+ * File names that suggest a supply side — read from the analysed sources, not from
41
+ * every path in the repository.
42
+ *
43
+ * `twentyhq/twenty`, an open-source CRM, ships an example real-estate app whose
44
+ * `roles/seller.role.ts` matched here. Scanning `files.all` meant any path anywhere
45
+ * counted, including bundled demos and vendored code.
46
+ */
47
+ const roleFiles = scope.filter((file) => /(seller|merchant|storefront)/i.test(file)).slice(0, 20);
42
48
  for (const file of roleFiles)
43
49
  evidence.push({ type: 'file', value: file });
44
50
  // Both sides must appear for a confident signal: a supply-side vocabulary on its own
45
51
  // also fits a plain catalogue or CMS, so it downgrades to partial rather than present.
46
- const bothSides = sellerHits.length > 0 && buyerHits.length > 0;
47
- const oneSide = sellerHits.length > 0 || buyerHits.length > 0 || roleFiles.length > 0;
52
+ /**
53
+ * Two sides are not one sentence.
54
+ *
55
+ * `documenso`, an open-source document-signing product, came out a marketplace with
56
+ * high confidence on a single line: a comment in a field-detection schema listing
57
+ * `"Tenant", "Landlord", "Buyer", "Seller"` as examples of labels found in the
58
+ * documents its users sign. One line matched both sides, and one line is one
59
+ * mention.
60
+ */
61
+ const vocabularyFiles = new Set([...sellerHits, ...buyerHits].map((hit) => hit.file));
62
+ const bothSides = sellerHits.length > 0 && buyerHits.length > 0 && vocabularyFiles.size >= 2;
63
+ // A file name on its own is not a role either. Twenty had four such names and no
64
+ // buyer or seller vocabulary anywhere in its code, and came out a marketplace:
65
+ // naming a file is cheaper than building a two-sided product.
66
+ const oneSide = vocabularyFiles.size >= 2;
48
67
  return {
49
68
  key: 'marketplace.multiRole',
50
69
  present: oneSide,
@@ -78,7 +97,18 @@ async function detectPayout(ctx) {
78
97
  }
79
98
  async function detectCommission(ctx) {
80
99
  const evidence = [];
81
- const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/\bcommission/i, /application_fee/i, /\bplatform_?fee/i, /\btake_?rate/i, /\bservice_?fee/i], 20);
100
+ const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
101
+ // "Commission" is also an institution. The privacy policy of an open-source CRM
102
+ // said "European Commission, relying on an adequacy decision" and was read as a
103
+ // platform taking a cut, sixteen times over.
104
+ /commission[_\s]?(rate|fee|percent|amount|bps)/i,
105
+ /(rate|fee|percent|amount)[_\s]?commission/i,
106
+ /\bcommission[A-Z]/,
107
+ /application_fee/i,
108
+ /\bplatform_?fee/i,
109
+ /\btake_?rate/i,
110
+ /\bservice_?fee/i,
111
+ ], 20);
82
112
  for (const hit of hits)
83
113
  evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
84
114
  return {
@@ -287,19 +287,51 @@ function evidenceQualityFor(detectors) {
287
287
  }
288
288
  return 'weak';
289
289
  }
290
- function confidenceFor(status, profileMode, evidenceQuality) {
291
- if (profileMode !== 'observed-only' && profileMode !== 'auto' && status !== 'unknown') {
292
- return 'high';
293
- }
294
- if (profileMode === 'auto') {
295
- return evidenceQuality === 'strong' ? 'medium' : 'low';
296
- }
290
+ /**
291
+ * How sure the analyzer is about *this* finding.
292
+ *
293
+ * It used to fold in how the profile was chosen: on `auto` — which is what the hosted
294
+ * product uses by default — every finding came out low or medium however strong its
295
+ * evidence. Two different uncertainties were being multiplied into one number, and the
296
+ * profile's own uncertainty is already reported separately as `inferenceConfidence`.
297
+ *
298
+ * An independent review of a real report put it plainly: eleven findings of
299
+ * thirty-two carried "no direct evidence captured" at low confidence and weak
300
+ * evidence, and still arrived as critical or high.
301
+ */
302
+ function confidenceFor(status, evidenceQuality) {
303
+ // Nothing was determined, so there is nothing to be confident about.
304
+ if (status === 'unknown')
305
+ return 'low';
297
306
  if (evidenceQuality === 'strong')
298
307
  return 'high';
299
308
  if (evidenceQuality === 'medium')
300
309
  return 'medium';
301
310
  return 'low';
302
311
  }
312
+ /**
313
+ * Severity says how bad it would be if true. Confidence says whether it is.
314
+ *
315
+ * A reader treats `critical` as "stop and fix this", and spending that word on a claim
316
+ * the analyzer itself is unsure of is how a tool becomes a checklist nobody trusts.
317
+ * The claim stays, at the weight the evidence supports.
318
+ */
319
+ function severityForConfidence(severity, confidence) {
320
+ if (confidence !== 'low')
321
+ return severity;
322
+ /**
323
+ * `critical` only. Not a ceiling at `medium`, which was the first attempt and was
324
+ * wrong for a reason worth writing down: a missing capability cannot carry direct
325
+ * evidence — there is no line to point at for something that is not there — so every
326
+ * absence reads as low confidence, and capping them all at `medium` would leave the
327
+ * report unable to say anything is serious.
328
+ *
329
+ * What it can stop doing is shouting. `critical` is read as "stop and fix this
330
+ * before anything else", and the analyzer should not spend that word on a claim it
331
+ * could not evidence.
332
+ */
333
+ return severity === 'critical' ? 'high' : severity;
334
+ }
303
335
  /**
304
336
  * Which capabilities each declaration makes required.
305
337
  *
@@ -390,10 +422,11 @@ function evaluateExpectedCapabilities(args) {
390
422
  effectiveImportance = 'not_applicable';
391
423
  }
392
424
  const findingId = toFindingId(cap, effectiveImportance);
393
- const severity = severityFor(cap, status, effectiveImportance);
425
+ const claimedSeverity = severityFor(cap, status, effectiveImportance);
394
426
  const detectorEvidence = evidenceFor(args.analysis, cap).flatMap((d) => d.evidence);
395
427
  const quality = evidenceQualityFor(evidenceFor(args.analysis, cap));
396
- const confidence = confidenceFor(status, args.requestedProfile, quality);
428
+ const confidence = confidenceFor(status, quality);
429
+ const severity = severityForConfidence(claimedSeverity, confidence);
397
430
  const evaluation = {
398
431
  capabilityId: cap.id,
399
432
  title: cap.title,
@@ -52,7 +52,16 @@ function buildCategoryScores(findings) {
52
52
  findingCount: actionable.length,
53
53
  criticalCount: actionable.filter((finding) => finding.severity === 'critical').length,
54
54
  highCount: actionable.filter((finding) => finding.severity === 'high').length,
55
- notAssessed: categoryFindings.length === 0,
55
+ /**
56
+ * "I don't know" is not an assessment.
57
+ *
58
+ * This was `categoryFindings.length === 0`, so a single `info` finding with
59
+ * status `unknown` — the analyzer recording that it could not tell — made the
60
+ * category count as assessed, with zero actionable findings and a score of 100.
61
+ * A Django school platform with no payments anywhere was reported as having
62
+ * "no issues found in taking payments", as a strength.
63
+ */
64
+ notAssessed: categoryFindings.every((finding) => finding.status === 'unknown'),
56
65
  };
57
66
  });
58
67
  }
@@ -83,8 +83,19 @@ function buildVerdict(args) {
83
83
  }
84
84
  function buildStrengths(categoryScores, findings) {
85
85
  const passed = findings.filter((finding) => finding.status === 'passed');
86
+ /**
87
+ * A strength is something verified, not something absent.
88
+ *
89
+ * Filtering on "assessed, scoring well, nothing open" let a category qualify on the
90
+ * strength of having nothing to say about it. Requiring a passed check means the
91
+ * report only calls something a strength when it watched it work.
92
+ */
93
+ const passedCategories = new Set(passed.map((finding) => finding.category));
86
94
  const strongCategories = categoryScores
87
- .filter((entry) => !entry.notAssessed && entry.score >= 80 && entry.findingCount === 0)
95
+ .filter((entry) => !entry.notAssessed
96
+ && entry.score >= 80
97
+ && entry.findingCount === 0
98
+ && passedCategories.has(entry.category))
88
99
  .map((entry) => CATEGORY_LABEL[entry.category]);
89
100
  const strengths = strongCategories.slice(0, 3).map((label) => `no issues found in ${label}`);
90
101
  if (strengths.length === 0 && passed.length > 0) {
@@ -46,6 +46,23 @@ function evidenceQualityFor(evidence) {
46
46
  }
47
47
  return 'weak';
48
48
  }
49
+ /** Severity is capped by how sure the analyzer is. See the note where it is used. */
50
+ function severityForConfidence(severity, confidence) {
51
+ if (confidence !== 'low')
52
+ return severity;
53
+ /**
54
+ * `critical` only. Not a ceiling at `medium`, which was the first attempt and was
55
+ * wrong for a reason worth writing down: a missing capability cannot carry direct
56
+ * evidence — there is no line to point at for something that is not there — so every
57
+ * absence reads as low confidence, and capping them all at `medium` would leave the
58
+ * report unable to say anything is serious.
59
+ *
60
+ * What it can stop doing is shouting. `critical` is read as "stop and fix this
61
+ * before anything else", and the analyzer should not spend that word on a claim it
62
+ * could not evidence.
63
+ */
64
+ return severity === 'critical' ? 'high' : severity;
65
+ }
49
66
  function confidenceFor(status, evidenceQuality) {
50
67
  if (status === 'passed' && evidenceQuality === 'strong') {
51
68
  return 'high';
@@ -60,16 +77,24 @@ function confidenceFor(status, evidenceQuality) {
60
77
  }
61
78
  function mkFinding(args) {
62
79
  const evidenceQuality = evidenceQualityFor(args.evidence);
80
+ const confidence = confidenceFor(args.status, evidenceQuality);
63
81
  return {
64
82
  id: args.id,
65
83
  title: args.title,
66
84
  category: args.category,
67
85
  status: args.status,
68
- severity: args.severity,
86
+ /**
87
+ * The same rule the expectations follow: severity says how bad it would be if true,
88
+ * confidence says whether it is, and a reader treats `critical` as "stop and fix
89
+ * this". Spending that word on something the analyzer is unsure of is how a report
90
+ * stops being believed — and it was doing it on both sides of the report, not only
91
+ * in the expectation half.
92
+ */
93
+ severity: severityForConfidence(args.severity, confidence),
69
94
  description: args.description,
70
95
  recommendation: args.recommendation,
71
96
  evidence: detectorEvidence(args.evidence),
72
- confidence: confidenceFor(args.status, evidenceQuality),
97
+ confidence,
73
98
  evidenceQuality,
74
99
  };
75
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {