@produtype/core 0.4.0 → 0.6.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.
- package/README.md +11 -0
- package/dist/analyzer/analyzeProject.js +29 -4
- package/dist/api.d.ts +1 -1
- package/dist/expectations/evaluateExpectations.d.ts +2 -1
- package/dist/expectations/evaluateExpectations.js +50 -1
- package/dist/expectations/productProfiles.d.ts +1 -1
- package/dist/expectations/productProfiles.js +3 -3
- package/dist/expectations/types.d.ts +18 -0
- package/dist/mcp/server.d.ts +2 -2
- package/dist/mcp/server.js +33 -6
- package/dist/report/buildReport.d.ts +10 -1
- package/dist/report/buildReport.js +2 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -45,6 +45,17 @@ npm install -g @produtype/core
|
|
|
45
45
|
prodkit analyze .
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
The MCP server needs one extra package, and only if you use it:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install @modelcontextprotocol/sdk # for prodkit-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It is an optional peer dependency rather than a dependency because it brings 164
|
|
55
|
+
packages with it — nine tenths of what this package used to install — for a server most
|
|
56
|
+
people never run, along with network, shell and eval access that the analyzer itself
|
|
57
|
+
does not use. Installing this package alone brings 19.
|
|
58
|
+
|
|
48
59
|
The command is `prodkit`; the package is `@produtype/core`. They differ on purpose:
|
|
49
60
|
`prodkit` on npm is an unrelated and actively maintained package, so this one is
|
|
50
61
|
published under the `@produtype` scope. `npm i prodkit` installs somebody else's
|
|
@@ -374,10 +374,18 @@ async function analyzeProject(projectPath) {
|
|
|
374
374
|
* group and artifact, without the version — because every rule downstream asks which
|
|
375
375
|
* library is used, never which release of it.
|
|
376
376
|
*
|
|
377
|
-
* Version catalogs
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
377
|
+
* Version catalogs are read too, from their own file rather than by following the
|
|
378
|
+
* alias. That decision was made the other way this morning and was wrong in
|
|
379
|
+
* practice: pointed at android/nowinandroid — Google's own sample, built to
|
|
380
|
+
* demonstrate offline-first — the analyzer reported no local database, because every
|
|
381
|
+
* module says `implementation(libs.room.runtime)` and the coordinates live in
|
|
382
|
+
* `gradle/libs.versions.toml`. Version catalogs are the recommended practice, so
|
|
383
|
+
* skipping them failed precisely on the projects that follow it.
|
|
384
|
+
*
|
|
385
|
+
* No alias resolution is needed: the catalog declares `group` and `name` outright.
|
|
386
|
+
* The cost is that a library declared in the catalog and used by no module is still
|
|
387
|
+
* reported, which is the direction to err in — the catalog is the project's own
|
|
388
|
+
* statement about what it builds with.
|
|
381
389
|
*/
|
|
382
390
|
const gradleDeps = [];
|
|
383
391
|
for (const file of allFiles.filter((f) => /(^|\/)build\.gradle(\.kts)?$/.test(f))) {
|
|
@@ -388,6 +396,23 @@ async function analyzeProject(projectPath) {
|
|
|
388
396
|
gradleDeps.push(`${group}:${artifact}`.toLowerCase());
|
|
389
397
|
}
|
|
390
398
|
}
|
|
399
|
+
for (const file of allFiles.filter((f) => /(^|\/)libs\.versions\.toml$/.test(f))) {
|
|
400
|
+
const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
|
|
401
|
+
// Two spellings, both common: group and name as separate keys, or one `module`
|
|
402
|
+
// holding the coordinate. Order within the line varies, so each is matched on its
|
|
403
|
+
// own rather than as one pattern per line.
|
|
404
|
+
for (const line of raw.split('\n')) {
|
|
405
|
+
const module = /module\s*=\s*["']([^"':]+):([^"']+)["']/.exec(line);
|
|
406
|
+
if (module) {
|
|
407
|
+
gradleDeps.push(`${module[1]}:${module[2]}`.toLowerCase());
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const group = /group\s*=\s*["']([^"']+)["']/.exec(line);
|
|
411
|
+
const name = /\bname\s*=\s*["']([^"']+)["']/.exec(line);
|
|
412
|
+
if (group && name)
|
|
413
|
+
gradleDeps.push(`${group[1]}:${name[1]}`.toLowerCase());
|
|
414
|
+
}
|
|
415
|
+
}
|
|
391
416
|
/**
|
|
392
417
|
* Swift packages, from Package.swift and the Podfile.
|
|
393
418
|
*
|
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
|
-
|
|
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;
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
export declare function createProdkitMcpServer(): McpServer
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export declare function createProdkitMcpServer(): Promise<McpServer>;
|
|
3
3
|
export declare function startProdkitMcpServer(): Promise<void>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createProdkitMcpServer = createProdkitMcpServer;
|
|
4
4
|
exports.startProdkitMcpServer = startProdkitMcpServer;
|
|
5
|
-
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
|
-
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
5
|
const zod_1 = require("zod");
|
|
8
6
|
const analyzeProject_1 = require("../analyzer/analyzeProject");
|
|
9
7
|
const buildReport_1 = require("../report/buildReport");
|
|
@@ -36,8 +34,36 @@ function errorResult(error) {
|
|
|
36
34
|
content: [{ type: 'text', text: `ProdKit could not analyse that path: ${message}` }],
|
|
37
35
|
};
|
|
38
36
|
}
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The MCP SDK, loaded when the server starts rather than when this package is imported.
|
|
39
|
+
*
|
|
40
|
+
* It was a plain dependency, and it brought 164 of this package's 183 installed
|
|
41
|
+
* packages with it — so everyone installing the analyzer as a library or a CLI paid
|
|
42
|
+
* for a server they may never run, and inherited its supply-chain surface: network,
|
|
43
|
+
* shell and eval, none of which the analyzer itself does.
|
|
44
|
+
*
|
|
45
|
+
* The same shape the AI layer uses for the Anthropic SDK: a variable specifier so the
|
|
46
|
+
* TypeScript build does not need it, a webpackIgnore hint so a bundler does not try to
|
|
47
|
+
* resolve it, and an error that says exactly what to install when it is absent.
|
|
48
|
+
*/
|
|
49
|
+
const SDK_MCP = '@modelcontextprotocol/sdk/server/mcp.js';
|
|
50
|
+
const SDK_STDIO = '@modelcontextprotocol/sdk/server/stdio.js';
|
|
51
|
+
async function loadMcpSdk() {
|
|
52
|
+
try {
|
|
53
|
+
const [mcp, stdio] = await Promise.all([
|
|
54
|
+
import(/* webpackIgnore: true */ SDK_MCP),
|
|
55
|
+
import(/* webpackIgnore: true */ SDK_STDIO),
|
|
56
|
+
]);
|
|
57
|
+
return { McpServer: mcp.McpServer, StdioServerTransport: stdio.StdioServerTransport };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error('The MCP server needs the optional @modelcontextprotocol/sdk package. '
|
|
61
|
+
+ 'Install it alongside this one: npm install @modelcontextprotocol/sdk');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function createProdkitMcpServer() {
|
|
65
|
+
const { McpServer: Server } = await loadMcpSdk();
|
|
66
|
+
const server = new Server({ name: 'prodkit', version: version_1.PRODKit_VERSION });
|
|
41
67
|
server.registerTool('list_profiles', {
|
|
42
68
|
title: 'List product profiles',
|
|
43
69
|
description: 'Lists the product profiles ProdKit can evaluate a repository against, with the capabilities each one expects.',
|
|
@@ -150,6 +176,7 @@ function createProdkitMcpServer() {
|
|
|
150
176
|
return server;
|
|
151
177
|
}
|
|
152
178
|
async function startProdkitMcpServer() {
|
|
153
|
-
const
|
|
154
|
-
|
|
179
|
+
const { StdioServerTransport } = await loadMcpSdk();
|
|
180
|
+
const server = await createProdkitMcpServer();
|
|
181
|
+
await server.connect(new StdioServerTransport());
|
|
155
182
|
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -43,12 +43,20 @@
|
|
|
43
43
|
"node": ">=18"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
47
46
|
"commander": "^12.1.0",
|
|
48
47
|
"fast-glob": "^3.3.2",
|
|
49
48
|
"zod": "^3.23.8"
|
|
50
49
|
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@modelcontextprotocol/sdk": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
51
58
|
"devDependencies": {
|
|
59
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
52
60
|
"@types/node": "^22.20.3",
|
|
53
61
|
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
|
54
62
|
"@typescript-eslint/parser": "^7.16.0",
|