@produtype/core 0.45.0 → 0.47.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.
@@ -287,6 +287,15 @@ function buildReport(analysis, options) {
287
287
  const maturityLevel = overallScore === null
288
288
  ? 'inconclusive'
289
289
  : (0, score_1.computeMaturity)(overallScore, coverage, openCriticals);
290
+ /**
291
+ * The observed half goes with it.
292
+ *
293
+ * "Overall score: not scored" printed directly above "Observed score: 100/100" —
294
+ * the report refusing to characterise a repository and then praising it in the next
295
+ * line. The observed number is the same arithmetic over the same three checks: if
296
+ * it cannot support a reading it cannot support half of one either.
297
+ */
298
+ const reportedObservedScore = inconclusive ? null : observedScore;
290
299
  const findingsByCategory = Object.fromEntries(types_1.CATEGORIES.map((c) => [c, []]));
291
300
  for (const f of findings)
292
301
  findingsByCategory[f.category].push(f);
@@ -334,7 +343,7 @@ function buildReport(analysis, options) {
334
343
  categoryScores,
335
344
  profile: productProfile,
336
345
  maturity: maturityLevel,
337
- observedScore,
346
+ observedScore: reportedObservedScore,
338
347
  overallScore,
339
348
  inconclusive,
340
349
  inconclusiveReasons,
@@ -365,7 +374,7 @@ function buildReport(analysis, options) {
365
374
  return {
366
375
  projectPath: analysis.projectPath,
367
376
  generatedAt: new Date().toISOString(),
368
- observedScore,
377
+ observedScore: reportedObservedScore,
369
378
  expectedCapabilityScore: expectationScore,
370
379
  overallScore,
371
380
  maturityLevel,
@@ -2,7 +2,14 @@ import type { Category, Finding } from './types';
2
2
  export interface CategoryScore {
3
3
  category: Category;
4
4
  /** 0-100. 100 means nothing actionable was found in this category. */
5
- score: number;
5
+ /**
6
+ * `null` where nothing in the analysis touched this category.
7
+ *
8
+ * `notAssessed` has been beside it for a while and the number went on saying 100.
9
+ * On a report with no overall score at all, twelve categories still read 100 of 100
10
+ * — a repository the analyzer could not read, presented as perfect in twelve areas.
11
+ */
12
+ score: number | null;
6
13
  findingCount: number;
7
14
  /** Checks that ran and found what they were looking for. */
8
15
  verifiedCount: number;
@@ -103,9 +103,10 @@ function buildCategoryScores(findings) {
103
103
  */
104
104
  const worstOpen = actionable.reduce((worst, finding) => (SEVERITY_WEIGHT[finding.severity] > SEVERITY_WEIGHT[worst] ? finding.severity : worst), 'info');
105
105
  const ceiling = SEVERITY_CEILING[worstOpen];
106
+ const notAssessed = categoryFindings.every((finding) => finding.status === 'unknown');
106
107
  return {
107
108
  category,
108
- score: Math.max(0, Math.min(100, proportional, ceiling)),
109
+ score: notAssessed ? null : Math.max(0, Math.min(100, proportional, ceiling)),
109
110
  findingCount: actionable.length,
110
111
  /** Checks in this category that ran and found what they were looking for. */
111
112
  verifiedCount: assessed.filter((finding) => finding.status === 'passed').length,
@@ -122,7 +123,7 @@ function buildCategoryScores(findings) {
122
123
  * A Django school platform with no payments anywhere was reported as having
123
124
  * "no issues found in taking payments", as a strength.
124
125
  */
125
- notAssessed: categoryFindings.every((finding) => finding.status === 'unknown'),
126
+ notAssessed,
126
127
  };
127
128
  });
128
129
  }
@@ -130,6 +131,6 @@ function buildCategoryScores(findings) {
130
131
  function weakestCategories(scores, limit = 3) {
131
132
  return [...scores]
132
133
  .filter((entry) => !entry.notAssessed && entry.findingCount > 0)
133
- .sort((left, right) => left.score - right.score || right.criticalCount - left.criticalCount)
134
+ .sort((left, right) => (left.score ?? 100) - (right.score ?? 100) || right.criticalCount - left.criticalCount)
134
135
  .slice(0, limit);
135
136
  }
@@ -9,8 +9,28 @@ export interface ComplianceReference {
9
9
  export interface ComplianceObligation extends ComplianceReference {
10
10
  /** Findings that leave this obligation unmet. */
11
11
  findingIds: string[];
12
+ /**
13
+ * `unknown` where every check behind this obligation came back `unknown`.
14
+ *
15
+ * This was a boolean, and `met` was the default: an obligation whose only supporting
16
+ * check could not reach a verdict was reported as satisfied. Measured across
17
+ * seventy-eight repositories, 84 of 163 obligations marked met rested on not one
18
+ * passing check — most of them OWASP A01, broken access control, declared met
19
+ * because a single check said it did not know.
20
+ *
21
+ * The comment on the builder already stated the principle and the code applied it
22
+ * only halfway: it omitted obligations that nothing mapped to, and marked met the
23
+ * ones that mapped to silence.
24
+ */
25
+ status: 'met' | 'unmet' | 'unknown';
26
+ /** @deprecated Read `status`. True only where `status` is `met`. */
12
27
  met: boolean;
13
28
  }
29
+ /**
30
+ * Exported so a test can ask the same question the builder asks, rather than keeping
31
+ * a second copy of the prefix table that would drift from this one.
32
+ */
33
+ export declare function referencesFor(finding: Finding): ComplianceReference[];
14
34
  /**
15
35
  * Returns every obligation touched by the analysis, marking as unmet those with at
16
36
  * least one actionable finding attached. An obligation nothing mapped to is omitted
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.referencesFor = referencesFor;
3
4
  exports.buildComplianceMapping = buildComplianceMapping;
4
5
  /**
5
6
  * Maps findings onto the obligations a reader has to answer to.
@@ -82,6 +83,10 @@ const RULES = [
82
83
  references: [{ framework: 'owasp-top-10', reference: 'A08', title: 'Software and data integrity failures' }],
83
84
  },
84
85
  ];
86
+ /**
87
+ * Exported so a test can ask the same question the builder asks, rather than keeping
88
+ * a second copy of the prefix table that would drift from this one.
89
+ */
85
90
  function referencesFor(finding) {
86
91
  for (const rule of RULES) {
87
92
  if (finding.id.startsWith(rule.prefix))
@@ -108,15 +113,39 @@ function buildComplianceMapping(findings) {
108
113
  obligations.set(key, {
109
114
  ...reference,
110
115
  findingIds: actionable ? [finding.id] : [],
111
- met: !actionable,
116
+ status: 'unknown',
117
+ met: false,
118
+ verified: finding.status === 'passed',
112
119
  });
113
120
  continue;
114
121
  }
115
- if (actionable) {
122
+ if (actionable)
116
123
  existing.findingIds.push(finding.id);
117
- existing.met = false;
118
- }
124
+ if (finding.status === 'passed')
125
+ existing.verified = true;
119
126
  }
120
127
  }
121
- return [...obligations.values()].sort((left, right) => left.framework.localeCompare(right.framework) || left.reference.localeCompare(right.reference));
128
+ /**
129
+ * An obligation is met when something was checked and found right.
130
+ *
131
+ * Three outcomes, because the reader needs to tell them apart before quoting any of
132
+ * this to an auditor: something is wrong here, something was verified here, and
133
+ * nothing here reached a verdict.
134
+ */
135
+ for (const obligation of obligations.values()) {
136
+ obligation.status = obligation.findingIds.length > 0
137
+ ? 'unmet'
138
+ : obligation.verified ? 'met' : 'unknown';
139
+ obligation.met = obligation.status === 'met';
140
+ }
141
+ return [...obligations.values()]
142
+ .map((obligation) => ({
143
+ framework: obligation.framework,
144
+ reference: obligation.reference,
145
+ title: obligation.title,
146
+ findingIds: obligation.findingIds,
147
+ status: obligation.status,
148
+ met: obligation.met,
149
+ }))
150
+ .sort((left, right) => left.framework.localeCompare(right.framework) || left.reference.localeCompare(right.reference));
122
151
  }
@@ -20,7 +20,7 @@ export declare function buildExecutiveSummary(args: {
20
20
  categoryScores: CategoryScore[];
21
21
  profile: ProductExpectationResult | undefined;
22
22
  maturity: MaturityLevel;
23
- observedScore: number;
23
+ observedScore: number | null;
24
24
  overallScore: number | null;
25
25
  inconclusive: boolean;
26
26
  /** Why, when the report could not form a reading. Named in the verdict. */
@@ -158,7 +158,7 @@ function buildStrengths(categoryScores, findings) {
158
158
  const passedCategories = new Set(passed.map((finding) => finding.category));
159
159
  const strongCategories = categoryScores
160
160
  .filter((entry) => !entry.notAssessed
161
- && entry.score >= 80
161
+ && (entry.score ?? 0) >= 80
162
162
  && entry.findingCount === 0
163
163
  && passedCategories.has(entry.category));
164
164
  /**
@@ -40,8 +40,10 @@ function profileSummary(report) {
40
40
  `- Selected profile: ${profile.selectedProfile}`,
41
41
  `- Profile: ${profile.profileTitle}`,
42
42
  `- Description: ${profile.profileDescription}`,
43
- `- Observed score: ${report.observedScore}/100`,
44
- `- Expected capability score: ${report.expectedCapabilityScore}/100`,
43
+ report.observedScore === null ? '- Observed score: not scored' : `- Observed score: ${report.observedScore}/100`,
44
+ report.expectedCapabilityScore === undefined
45
+ ? '- Expected capability score: not measured'
46
+ : `- Expected capability score: ${report.expectedCapabilityScore}/100`,
45
47
  report.overallScore === null ? '- Final score: not scored' : `- Final score: ${report.overallScore}/100`,
46
48
  '- Capability summary:',
47
49
  `- Required: ${requiredPresent} present / ${requiredMissing} missing / ${requiredPartial} partial`,
@@ -134,25 +136,47 @@ function categoryScoreSection(report) {
134
136
  */
135
137
  '| Area | Score | Verified | Open findings | Critical |',
136
138
  '| --- | --- | --- | --- | --- |',
137
- ...assessed.map((entry) => `| ${entry.category} | ${entry.score}/100 | ${entry.verifiedCount} of ${entry.assessedCount} | ${entry.findingCount} | ${entry.criticalCount} |`),
139
+ ...assessed.map((entry) => `| ${entry.category} | ${entry.score === null ? 'not scored' : `${entry.score}/100`} | ${entry.verifiedCount} of ${entry.assessedCount} | ${entry.findingCount} | ${entry.criticalCount} |`),
138
140
  '',
139
141
  ];
140
142
  }
141
143
  function complianceSection(report) {
142
144
  if (report.compliance.length === 0)
143
145
  return [];
144
- const unmet = report.compliance.filter((obligation) => !obligation.met);
145
- if (unmet.length === 0)
146
+ /**
147
+ * Exposure and silence, listed apart.
148
+ *
149
+ * This filtered on `!met`, which was two different things wearing one label: an
150
+ * obligation with findings against it, and an obligation whose every check came
151
+ * back unknown. The second was printed under "Compliance Exposure" with a findings
152
+ * count of zero — a reader would quote that to an auditor as a gap when it is the
153
+ * absence of an answer.
154
+ */
155
+ const unmet = report.compliance.filter((obligation) => obligation.status === 'unmet');
156
+ const unassessed = report.compliance.filter((obligation) => obligation.status === 'unknown');
157
+ if (unmet.length === 0 && unassessed.length === 0)
146
158
  return [];
147
159
  return [
148
160
  '## Compliance Exposure',
149
161
  '',
150
162
  '_Advisory mapping, not a compliance certification. Obligations nothing mapped to are omitted rather than reported as met._',
151
163
  '',
152
- '| Framework | Reference | Obligation | Findings |',
153
- '| --- | --- | --- | --- |',
154
- ...unmet.map((obligation) => `| ${obligation.framework} | ${obligation.reference} | ${obligation.title} | ${obligation.findingIds.length} |`),
155
- '',
164
+ ...(unmet.length > 0
165
+ ? [
166
+ '| Framework | Reference | Obligation | Findings |',
167
+ '| --- | --- | --- | --- |',
168
+ ...unmet.map((obligation) => `| ${obligation.framework} | ${obligation.reference} | ${obligation.title} | ${obligation.findingIds.length} |`),
169
+ '',
170
+ ]
171
+ : ['No obligation in this mapping has a finding against it.', '']),
172
+ ...(unassessed.length > 0
173
+ ? [
174
+ `Not assessed — every check behind these came back unknown, so this report says nothing either way about ${unassessed.length === 1 ? 'it' : 'them'}:`,
175
+ '',
176
+ ...unassessed.map((obligation) => `- ${obligation.framework} ${obligation.reference}: ${obligation.title}`),
177
+ '',
178
+ ]
179
+ : []),
156
180
  ];
157
181
  }
158
182
  function renderMarkdown(report) {
@@ -222,7 +246,7 @@ function renderMarkdown(report) {
222
246
  '## Score',
223
247
  '',
224
248
  report.overallScore === null ? '- Overall score: not scored' : `- Overall score: ${report.overallScore}/100`,
225
- `- Observed score: ${report.observedScore}/100`,
249
+ report.observedScore === null ? '- Observed score: not scored' : `- Observed score: ${report.observedScore}/100`,
226
250
  ...(report.expectedCapabilityScore !== undefined ? [`- Expected capability score: ${report.expectedCapabilityScore}/100`] : []),
227
251
  `- Maturity level: ${report.maturityLevel}${report.inconclusive ? ' (inconclusive)' : ''}`,
228
252
  /**
@@ -238,7 +262,7 @@ function renderMarkdown(report) {
238
262
  : []),
239
263
  ...(report.inconclusive
240
264
  ? [
241
- '- Assessment: INCONCLUSIVE — the score is capped because the project could not be recognized:',
265
+ '- Assessment: INCONCLUSIVE — this report has no score, for these reasons:',
242
266
  ...report.inconclusiveReasons.map((r) => ` - ${r}`),
243
267
  ]
244
268
  : []),
@@ -88,7 +88,8 @@ export interface ReportDiagnostics {
88
88
  export interface ProductionReadinessReport {
89
89
  projectPath: string;
90
90
  generatedAt: string;
91
- observedScore: number;
91
+ /** `null` where the reading is inconclusive, for the same reason `overallScore` is. */
92
+ observedScore: number | null;
92
93
  expectedCapabilityScore?: number;
93
94
  /**
94
95
  * `null` where the reading is inconclusive, which is the same rule this product
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.45.0",
3
+ "version": "0.47.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": {