@produtype/core 0.5.0 → 0.7.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.
@@ -83,19 +83,32 @@ async function detectAuth(ctx) {
83
83
  /organizationId/i,
84
84
  /tenantId/i,
85
85
  /workspaceId/i,
86
- /teamId/i,
87
86
  ], 20);
88
- const organizationSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
89
- /organizationId/i,
90
- /tenantId/i,
91
- /workspaceId/i,
92
- /companyId/i,
93
- /teamId/i,
94
- /organization_id/i,
95
- /tenant_id/i,
96
- /workspace_id/i,
97
- ], 25);
98
- const membershipSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/memberId/i, /organizationId/i, /tenantId/i, /workspaceId/i, /teamId/i, /companyId/i], 25);
87
+ /**
88
+ * Words that only mean tenancy, and words that usually mean something else.
89
+ *
90
+ * `organizationId` and `tenantId` are not written by accident. `workspaceId` and
91
+ * `companyId` are common in SaaS and also in code that talks *about* somebody else's
92
+ * product, so they are corroborated across files before they count.
93
+ *
94
+ * `teamId` was a signal and is gone. In the JavaScript ecosystem it is overwhelmingly
95
+ * Apple's Developer Team ID: `usebruno/bruno`, a desktop API client with no accounts
96
+ * of any kind, was classified as a B2B SaaS with high confidence on the strength of
97
+ * `const teamId = 'W7LPPWA48L'` in its notarization script.
98
+ */
99
+ const STRONG_TENANCY = [/organizationId/i, /organization_id/i, /tenantId/i, /tenant_id/i];
100
+ const WEAK_TENANCY = [/workspaceId/i, /workspace_id/i, /companyId/i];
101
+ const strongOrganization = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, STRONG_TENANCY, 25);
102
+ const weakOrganization = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, WEAK_TENANCY, 25);
103
+ // A weak word never stands on its own, however many files it appears in. Bruno says
104
+ // `workspaceId` in three — it has workspaces, and they are local folders, not
105
+ // customers. A tenant boundary is named somewhere by a word that means only that.
106
+ const organizationSignals = strongOrganization.length > 0
107
+ ? [...strongOrganization, ...weakOrganization]
108
+ : [];
109
+ const strongMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/memberId/i, ...STRONG_TENANCY], 25);
110
+ const weakMembership = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, WEAK_TENANCY, 25);
111
+ const membershipSignals = strongMembership.length > 0 ? [...strongMembership, ...weakMembership] : [];
99
112
  const b2bSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
100
113
  /stripe/i,
101
114
  /subscription/i,
@@ -12,7 +12,18 @@ async function detectBilling(ctx) {
12
12
  if (hasStripeDep)
13
13
  evidence.push({ type: 'dependency', value: 'stripe' });
14
14
  const stripeContextHits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
15
- /\bstripe\b/i,
15
+ /**
16
+ * The bare word is not here, on purpose.
17
+ *
18
+ * `\bstripe\b` matched `'table-stripe': '#f3f3f3'`, a CSS colour token for
19
+ * zebra-striped tables, and `{['Stripe API', 'GitHub REST', …]}`, a list of API
20
+ * names in a design-system demo. Both are in `usebruno/bruno`, a desktop API
21
+ * client that takes no payments and was reported as a B2B SaaS.
22
+ *
23
+ * A project that actually charges people has the dependency, a STRIPE_ variable,
24
+ * a customer or subscription id, or a webhook path. One that only ever writes
25
+ * "Stripe" in prose is talking about Stripe, not billing through it.
26
+ */
16
27
  /STRIPE_[A-Z0-9_]+/,
17
28
  /stripeCustomerId/i,
18
29
  /stripeSubscriptionId/i,
package/dist/api.d.ts CHANGED
@@ -11,7 +11,7 @@ export type { ProductionReadinessReport, Finding, MaturityLevel, ReportDiagnosti
11
11
  export type { CategoryScore } from './report/categoryScores';
12
12
  export type { ExecutiveSummary } from './report/executiveSummary';
13
13
  export type { ComplianceObligation, ComplianceFramework } from './report/complianceMapping';
14
- export type { CapabilityGap } from './expectations/types';
14
+ export type { CapabilityGap, DeclaredIntent } from './expectations/types';
15
15
  export type { FindingConfidence, EvidenceQuality } from './report/types';
16
16
  export type { RemediationPlan, RemediationTask, RemediationPhase } from './planner/types';
17
17
  export type { BuildReportOptions } from './report/buildReport';
@@ -1,9 +1,10 @@
1
1
  import type { ProjectAnalysis } from '../analyzer/types';
2
- import type { ExpectationEvaluationOutput, ProductProfile } from './types';
2
+ import type { DeclaredIntent, ExpectationEvaluationOutput, ProductProfile } from './types';
3
3
  export declare function evaluateExpectedCapabilities(args: {
4
4
  analysis: ProjectAnalysis;
5
5
  selectedProfile: Exclude<ProductProfile, 'auto' | 'observed-only'>;
6
6
  requestedProfile: ProductProfile;
7
7
  inferredProfile?: ProductProfile;
8
8
  inferenceConfidence?: 'low' | 'medium' | 'high';
9
+ declared?: DeclaredIntent;
9
10
  }): ExpectationEvaluationOutput;
@@ -300,6 +300,55 @@ function confidenceFor(status, profileMode, evidenceQuality) {
300
300
  return 'medium';
301
301
  return 'low';
302
302
  }
303
+ /**
304
+ * Which capabilities each declaration makes required.
305
+ *
306
+ * One declaration usually implies several: saying you handle personal data is saying
307
+ * you owe consent, export, erasure and a retention position, not one of the four.
308
+ */
309
+ const DECLARED_CAPABILITIES = {
310
+ handlesPersonalData: ['gdpr.consent', 'gdpr.export', 'gdpr.erasure', 'gdpr.retention'],
311
+ hasFileUploads: ['uploads.protection'],
312
+ requiresTenantIsolation: ['tenancy.organization', 'tenancy.isolation'],
313
+ hasBilling: ['billing.model', 'billing.webhook-integrity'],
314
+ };
315
+ const IMPORTANCE_RANK = {
316
+ not_applicable: 0,
317
+ optional: 1,
318
+ recommended: 2,
319
+ required: 3,
320
+ };
321
+ /**
322
+ * The profile's capabilities, with what the owner declared folded in.
323
+ *
324
+ * Raising only, never lowering, and adding a capability the profile does not carry
325
+ * when the declaration calls for it — a static site that says it takes payments is
326
+ * asking to be judged on payments, and the static-site profile has nothing to say
327
+ * about them.
328
+ */
329
+ function applyDeclarations(capabilities, declared) {
330
+ if (!declared)
331
+ return capabilities;
332
+ const required = new Set();
333
+ for (const [key, ids] of Object.entries(DECLARED_CAPABILITIES)) {
334
+ // Only a `true` does anything. `false` is not evidence of absence, and treating it
335
+ // as such would let anyone switch a finding off by answering a form.
336
+ if (declared[key] === true)
337
+ for (const id of ids)
338
+ required.add(id);
339
+ }
340
+ if (required.size === 0)
341
+ return capabilities;
342
+ const out = capabilities.map((cap) => required.has(cap.id) && IMPORTANCE_RANK[cap.importance] < IMPORTANCE_RANK.required
343
+ ? { ...cap, importance: 'required' }
344
+ : cap);
345
+ const present = new Set(out.map((cap) => cap.id));
346
+ for (const id of required) {
347
+ if (!present.has(id))
348
+ out.push({ ...productProfiles_1.CAPABILITIES[id], importance: 'required' });
349
+ }
350
+ return out;
351
+ }
303
352
  function evaluateExpectedCapabilities(args) {
304
353
  const profile = (0, productProfiles_1.getProductProfile)(args.selectedProfile);
305
354
  const evaluations = [];
@@ -329,7 +378,7 @@ function evaluateExpectedCapabilities(args) {
329
378
  recommendedPartial: 0,
330
379
  };
331
380
  const authDetected = detector(args.analysis, 'auth.core')?.present === true;
332
- for (const cap of profile.capabilities) {
381
+ for (const cap of applyDeclarations(profile.capabilities, args.declared)) {
333
382
  let effectiveImportance = cap.importance;
334
383
  if (cap.id === 'gdpr.baseline' && profile.id === 'internal-tool' && !authDetected) {
335
384
  effectiveImportance = 'not_applicable';
@@ -16,7 +16,7 @@ type CapabilityBlueprint = Omit<ExpectedCapability, 'importance'>;
16
16
  * Every id here must have a case in `deriveStatus` in evaluateExpectations.ts,
17
17
  * otherwise it falls back to generic detectorKeys matching.
18
18
  */
19
- declare const CAPABILITIES: {
19
+ export declare const CAPABILITIES: {
20
20
  readonly 'auth.baseline': CapabilityBlueprint;
21
21
  readonly 'auth.mfa': CapabilityBlueprint;
22
22
  readonly 'auth.password-reset': CapabilityBlueprint;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.productProfiles = void 0;
3
+ exports.productProfiles = exports.CAPABILITIES = void 0;
4
4
  exports.productProfileChoices = productProfileChoices;
5
5
  exports.getProductProfile = getProductProfile;
6
6
  function blueprint(args) {
@@ -17,7 +17,7 @@ function blueprint(args) {
17
17
  * Every id here must have a case in `deriveStatus` in evaluateExpectations.ts,
18
18
  * otherwise it falls back to generic detectorKeys matching.
19
19
  */
20
- const CAPABILITIES = {
20
+ exports.CAPABILITIES = {
21
21
  'auth.baseline': blueprint({
22
22
  id: 'auth.baseline',
23
23
  title: 'Authentication baseline',
@@ -362,7 +362,7 @@ const CAPABILITIES = {
362
362
  */
363
363
  function defineProfile(args) {
364
364
  const capabilities = Object.entries(args.importance).map(([id, importance]) => ({
365
- ...CAPABILITIES[id],
365
+ ...exports.CAPABILITIES[id],
366
366
  importance: importance,
367
367
  }));
368
368
  return {
@@ -4,6 +4,24 @@ export type ProductProfile = 'static-site' | 'internal-tool' | 'b2c-app' | 'b2b-
4
4
  export type CapabilityImportance = 'required' | 'recommended' | 'optional' | 'not_applicable';
5
5
  export type CapabilityStatus = 'present' | 'missing' | 'partial' | 'unknown' | 'not_applicable';
6
6
  export type CapabilityCategory = 'auth' | 'authz' | 'tenancy' | 'gdpr' | 'billing' | 'security' | 'uploads' | 'observability' | 'deployment' | 'audit' | 'jobs' | 'client' | 'mobile';
7
+ /**
8
+ * What the owner says their product does, as distinct from what the code shows.
9
+ *
10
+ * A declaration can *add* a duty and can never remove one. Saying "we take payments"
11
+ * makes the billing capabilities required even where the profile treats them as
12
+ * optional and even where no Stripe call was found — the statement is evidence about
13
+ * intent, and a product that intends to charge people has to charge them safely.
14
+ *
15
+ * Saying "we have no file uploads" does nothing at all. If an upload route is in the
16
+ * code, the finding stands: otherwise this is a switch for turning problems off, and a
17
+ * score with an off switch measures the owner's optimism rather than the product.
18
+ */
19
+ export interface DeclaredIntent {
20
+ handlesPersonalData?: boolean;
21
+ hasFileUploads?: boolean;
22
+ requiresTenantIsolation?: boolean;
23
+ hasBilling?: boolean;
24
+ }
7
25
  export interface ExpectedCapability {
8
26
  id: string;
9
27
  title: string;
@@ -1,7 +1,16 @@
1
1
  import type { ProjectAnalysis } from '../analyzer/types';
2
2
  import type { ProductionReadinessReport } from './types';
3
- import type { ProductProfile } from '../expectations/types';
3
+ import type { DeclaredIntent, ProductProfile } from '../expectations/types';
4
4
  export interface BuildReportOptions {
5
5
  profile?: ProductProfile;
6
+ /**
7
+ * What the owner says the product does.
8
+ *
9
+ * Only ever raises an expectation. The cloud application collects these four answers
10
+ * when a project is created, displayed them as "Product intent", and never passed
11
+ * them here — so the same report could say "file uploads: No" and raise a critical
12
+ * about file uploads.
13
+ */
14
+ declared?: DeclaredIntent;
6
15
  }
7
16
  export declare function buildReport(analysis: ProjectAnalysis, options?: BuildReportOptions): ProductionReadinessReport;
@@ -87,6 +87,7 @@ function buildReport(analysis, options) {
87
87
  requestedProfile,
88
88
  inferredProfile: inferred.inferredProfile ?? undefined,
89
89
  inferenceConfidence: inferred.confidence,
90
+ declared: options?.declared,
90
91
  });
91
92
  productProfile = evaluated.result;
92
93
  expectationFindings = evaluated.findings;
@@ -98,6 +99,7 @@ function buildReport(analysis, options) {
98
99
  analysis,
99
100
  selectedProfile: requestedProfile,
100
101
  requestedProfile,
102
+ declared: options?.declared,
101
103
  });
102
104
  productProfile = evaluated.result;
103
105
  expectationFindings = evaluated.findings;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@produtype/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {