pnpmcheker 0.1.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/analyzer/index.d.ts +3 -0
- package/dist/analyzer/index.js +765 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/analyzer/node-support.d.ts +9 -0
- package/dist/analyzer/node-support.js +28 -0
- package/dist/analyzer/node-support.js.map +1 -0
- package/dist/analyzer/registry.d.ts +2 -0
- package/dist/analyzer/registry.js +27 -0
- package/dist/analyzer/registry.js.map +1 -0
- package/dist/analyzer/types.d.ts +46 -0
- package/dist/analyzer/types.js +2 -0
- package/dist/analyzer/types.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +68 -0
- package/dist/cli.js.map +1 -0
- package/dist/public/assets/index-DPxJsM8b.js +22 -0
- package/dist/public/assets/index-DfTqf-d1.css +1 -0
- package/dist/public/index.html +68 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +42 -0
- package/dist/server.js.map +1 -0
- package/package.json +81 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { daysUntil, findMinimumNodeMajor, getNodeLine } from "./node-support.js";
|
|
5
|
+
import { fetchPackageMetadata } from "./registry.js";
|
|
6
|
+
const LOCKFILES = [
|
|
7
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
8
|
+
{ file: "npm-shrinkwrap.json", manager: "npm" },
|
|
9
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
10
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
11
|
+
{ file: "bun.lock", manager: "bun" },
|
|
12
|
+
{ file: "bun.lockb", manager: "bun" }
|
|
13
|
+
];
|
|
14
|
+
const DEPENDENCY_SECTIONS = [
|
|
15
|
+
"dependencies",
|
|
16
|
+
"devDependencies",
|
|
17
|
+
"optionalDependencies",
|
|
18
|
+
"peerDependencies"
|
|
19
|
+
];
|
|
20
|
+
export async function analyzeProject(projectPath, options = {}) {
|
|
21
|
+
const targetPath = path.resolve(projectPath);
|
|
22
|
+
const now = options.now ?? new Date();
|
|
23
|
+
const checks = [];
|
|
24
|
+
const packageJsonPath = path.join(targetPath, "package.json");
|
|
25
|
+
const packageJson = await readJson(packageJsonPath);
|
|
26
|
+
if (!packageJson) {
|
|
27
|
+
checks.push({
|
|
28
|
+
id: "package-json-required",
|
|
29
|
+
category: "identity",
|
|
30
|
+
status: "fail",
|
|
31
|
+
title: "package.json is required",
|
|
32
|
+
summary: "No package.json was found at the selected path.",
|
|
33
|
+
recommendation: "Run this against an npm project root or add a package.json before evaluating project practices.",
|
|
34
|
+
evidence: [packageJsonPath]
|
|
35
|
+
});
|
|
36
|
+
return buildReport(targetPath, checks, [], [], now);
|
|
37
|
+
}
|
|
38
|
+
checks.push({
|
|
39
|
+
id: "package-json-present",
|
|
40
|
+
category: "identity",
|
|
41
|
+
status: "pass",
|
|
42
|
+
title: "Project manifest found",
|
|
43
|
+
summary: "package.json is present and readable.",
|
|
44
|
+
evidence: [packageJsonPath]
|
|
45
|
+
});
|
|
46
|
+
const packageManager = readString(packageJson.packageManager)?.split("@")[0];
|
|
47
|
+
const lockfiles = await detectLockfiles(targetPath);
|
|
48
|
+
addLockfileChecks(checks, lockfiles, packageManager, packageJson);
|
|
49
|
+
await addPnpmSecurityChecks(checks, targetPath, lockfiles, packageManager);
|
|
50
|
+
addRuntimeChecks(checks, packageJson, now);
|
|
51
|
+
await addNpmConfigChecks(checks, targetPath);
|
|
52
|
+
addScriptChecks(checks, packageJson);
|
|
53
|
+
await addAutomationChecks(checks, targetPath);
|
|
54
|
+
const tools = await suggestTools(targetPath, packageJson);
|
|
55
|
+
checks.push(...toolChecks(tools));
|
|
56
|
+
const packages = options.includeRegistryMetadata === false ? [] : await collectRegistryMetadata(packageJson);
|
|
57
|
+
addRegistryChecks(checks, packages, now);
|
|
58
|
+
return buildReport(targetPath, checks, tools, packages, now, readString(packageJson.name), readString(packageJson.packageManager));
|
|
59
|
+
}
|
|
60
|
+
function addLockfileChecks(checks, lockfiles, packageManager, packageJson) {
|
|
61
|
+
if (lockfiles.length === 0) {
|
|
62
|
+
checks.push({
|
|
63
|
+
id: "lockfile-present",
|
|
64
|
+
category: "lockfiles",
|
|
65
|
+
status: "fail",
|
|
66
|
+
title: "Lockfile is missing",
|
|
67
|
+
summary: "No package manager lockfile was found.",
|
|
68
|
+
recommendation: "Commit exactly one lockfile for applications so installs are reproducible in CI and on developer machines."
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
checks.push({
|
|
73
|
+
id: "lockfile-present",
|
|
74
|
+
category: "lockfiles",
|
|
75
|
+
status: "pass",
|
|
76
|
+
title: "Lockfile is committed",
|
|
77
|
+
summary: `${lockfiles.map((lockfile) => lockfile.file).join(", ")} found.`,
|
|
78
|
+
evidence: lockfiles.map((lockfile) => lockfile.file)
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (lockfiles.length > 1) {
|
|
82
|
+
checks.push({
|
|
83
|
+
id: "single-lockfile",
|
|
84
|
+
category: "lockfiles",
|
|
85
|
+
status: "warn",
|
|
86
|
+
title: "Multiple lockfiles detected",
|
|
87
|
+
summary: "More than one package manager lockfile is present.",
|
|
88
|
+
recommendation: "Keep one lockfile that matches the package manager used by the team.",
|
|
89
|
+
evidence: lockfiles.map((lockfile) => lockfile.file)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else if (lockfiles.length === 1) {
|
|
93
|
+
checks.push({
|
|
94
|
+
id: "single-lockfile",
|
|
95
|
+
category: "lockfiles",
|
|
96
|
+
status: "pass",
|
|
97
|
+
title: "Single package manager lockfile",
|
|
98
|
+
summary: "Only one lockfile was detected."
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (!packageManager) {
|
|
102
|
+
checks.push({
|
|
103
|
+
id: "package-manager-pinned",
|
|
104
|
+
category: "lockfiles",
|
|
105
|
+
status: "warn",
|
|
106
|
+
title: "Package manager is not pinned",
|
|
107
|
+
summary: "package.json does not define the packageManager field.",
|
|
108
|
+
recommendation: "Add packageManager, such as npm@latest, pnpm@latest, or yarn@stable, so Corepack and CI use the same toolchain."
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
checks.push({
|
|
113
|
+
id: "package-manager-pinned",
|
|
114
|
+
category: "lockfiles",
|
|
115
|
+
status: "pass",
|
|
116
|
+
title: "Package manager is pinned",
|
|
117
|
+
summary: `packageManager is set to ${packageJson.packageManager}.`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (packageManager && lockfiles.length > 0) {
|
|
121
|
+
const matching = lockfiles.some((lockfile) => lockfile.manager === packageManager);
|
|
122
|
+
checks.push({
|
|
123
|
+
id: "lockfile-manager-match",
|
|
124
|
+
category: "lockfiles",
|
|
125
|
+
status: matching ? "pass" : "fail",
|
|
126
|
+
title: matching ? "Lockfile matches package manager" : "Lockfile does not match package manager",
|
|
127
|
+
summary: matching
|
|
128
|
+
? `The detected lockfile matches ${packageManager}.`
|
|
129
|
+
: `packageManager is ${packageManager}, but detected lockfiles belong to ${lockfiles.map((lockfile) => lockfile.manager).join(", ")}.`,
|
|
130
|
+
recommendation: matching
|
|
131
|
+
? undefined
|
|
132
|
+
: "Regenerate the lockfile with the pinned package manager and remove stale lockfiles."
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const npmLock = lockfiles.find((lockfile) => lockfile.file === "package-lock.json");
|
|
136
|
+
if (npmLock?.version === "1") {
|
|
137
|
+
checks.push({
|
|
138
|
+
id: "package-lock-modern",
|
|
139
|
+
category: "lockfiles",
|
|
140
|
+
status: "warn",
|
|
141
|
+
title: "package-lock.json uses legacy format",
|
|
142
|
+
summary: "lockfileVersion 1 is from npm 6 and misses newer reproducibility metadata.",
|
|
143
|
+
recommendation: "Regenerate the lockfile with a current npm version."
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else if (npmLock) {
|
|
147
|
+
checks.push({
|
|
148
|
+
id: "package-lock-modern",
|
|
149
|
+
category: "lockfiles",
|
|
150
|
+
status: "pass",
|
|
151
|
+
title: "package-lock.json uses a modern format",
|
|
152
|
+
summary: `lockfileVersion ${npmLock.version ?? "unknown"} detected.`
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const directDeps = getDirectDependencyNames(packageJson);
|
|
156
|
+
if (directDeps.length === 0) {
|
|
157
|
+
checks.push({
|
|
158
|
+
id: "direct-dependencies-declared",
|
|
159
|
+
category: "lockfiles",
|
|
160
|
+
status: "info",
|
|
161
|
+
title: "No direct dependencies declared",
|
|
162
|
+
summary: "This package.json does not list dependencies or devDependencies."
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
checks.push({
|
|
167
|
+
id: "direct-dependencies-declared",
|
|
168
|
+
category: "lockfiles",
|
|
169
|
+
status: "pass",
|
|
170
|
+
title: "Direct dependencies are declared",
|
|
171
|
+
summary: `${directDeps.length} direct dependency entries found.`
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function addRuntimeChecks(checks, packageJson, now) {
|
|
176
|
+
const engines = asObject(packageJson.engines);
|
|
177
|
+
const nodeRange = readString(engines?.node);
|
|
178
|
+
if (!nodeRange) {
|
|
179
|
+
checks.push({
|
|
180
|
+
id: "node-engine-pinned",
|
|
181
|
+
category: "runtime",
|
|
182
|
+
status: "warn",
|
|
183
|
+
title: "Node version range is missing",
|
|
184
|
+
summary: "package.json does not define engines.node.",
|
|
185
|
+
recommendation: "Set engines.node to a supported LTS line and mirror it in CI."
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const minimumMajor = findMinimumNodeMajor(nodeRange);
|
|
190
|
+
const releaseLine = minimumMajor ? getNodeLine(minimumMajor) : undefined;
|
|
191
|
+
checks.push({
|
|
192
|
+
id: "node-engine-pinned",
|
|
193
|
+
category: "runtime",
|
|
194
|
+
status: "pass",
|
|
195
|
+
title: "Node version range is declared",
|
|
196
|
+
summary: `engines.node is set to ${nodeRange}.`
|
|
197
|
+
});
|
|
198
|
+
if (!minimumMajor || !releaseLine) {
|
|
199
|
+
checks.push({
|
|
200
|
+
id: "node-engine-supported",
|
|
201
|
+
category: "runtime",
|
|
202
|
+
status: "warn",
|
|
203
|
+
title: "Node support window could not be verified",
|
|
204
|
+
summary: `The analyzer could not map ${nodeRange} to a known Node release line.`,
|
|
205
|
+
recommendation: "Use a clear range such as >=22 <27 or >=24 <27."
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const remainingDays = daysUntil(releaseLine.eol, now);
|
|
210
|
+
if (releaseLine.status === "eol" || remainingDays < 0) {
|
|
211
|
+
checks.push({
|
|
212
|
+
id: "node-engine-supported",
|
|
213
|
+
category: "runtime",
|
|
214
|
+
status: "fail",
|
|
215
|
+
title: "Minimum Node version is end-of-life",
|
|
216
|
+
summary: `The minimum supported Node major appears to be ${minimumMajor}, which reached EOL on ${releaseLine.eol}.`,
|
|
217
|
+
recommendation: "Raise the supported runtime to an actively supported LTS line."
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (remainingDays <= 180) {
|
|
221
|
+
checks.push({
|
|
222
|
+
id: "node-engine-supported",
|
|
223
|
+
category: "runtime",
|
|
224
|
+
status: "warn",
|
|
225
|
+
title: "Minimum Node version expires soon",
|
|
226
|
+
summary: `Node ${minimumMajor} reaches EOL on ${releaseLine.eol}.`,
|
|
227
|
+
recommendation: "Plan the runtime upgrade before the support window closes."
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
checks.push({
|
|
232
|
+
id: "node-engine-supported",
|
|
233
|
+
category: "runtime",
|
|
234
|
+
status: "pass",
|
|
235
|
+
title: "Minimum Node version is supported",
|
|
236
|
+
summary: `Node ${minimumMajor} is supported until ${releaseLine.eol}.`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function addNpmConfigChecks(checks, targetPath) {
|
|
241
|
+
const npmrcPath = path.join(targetPath, ".npmrc");
|
|
242
|
+
const npmrc = await readText(npmrcPath);
|
|
243
|
+
if (!npmrc) {
|
|
244
|
+
checks.push({
|
|
245
|
+
id: "npmrc-present",
|
|
246
|
+
category: "security",
|
|
247
|
+
status: "info",
|
|
248
|
+
title: "No project .npmrc found",
|
|
249
|
+
summary: "Project-specific npm config was not detected."
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const hasInlineToken = /:_authToken\s*=\s*(?!\$\{)[^\s]+/.test(npmrc);
|
|
254
|
+
checks.push({
|
|
255
|
+
id: "npm-token-not-committed",
|
|
256
|
+
category: "security",
|
|
257
|
+
status: hasInlineToken ? "fail" : "pass",
|
|
258
|
+
title: hasInlineToken ? "Committed npm token detected" : "No committed npm token detected",
|
|
259
|
+
summary: hasInlineToken
|
|
260
|
+
? ".npmrc appears to contain a literal auth token."
|
|
261
|
+
: ".npmrc does not appear to contain a literal npm auth token.",
|
|
262
|
+
recommendation: hasInlineToken
|
|
263
|
+
? "Use environment-variable substitution such as ${NPM_TOKEN} and rotate any exposed token."
|
|
264
|
+
: undefined,
|
|
265
|
+
evidence: [".npmrc"]
|
|
266
|
+
});
|
|
267
|
+
const engineStrict = /^engine-strict\s*=\s*true$/m.test(npmrc);
|
|
268
|
+
checks.push({
|
|
269
|
+
id: "engine-strict",
|
|
270
|
+
category: "runtime",
|
|
271
|
+
status: engineStrict ? "pass" : "warn",
|
|
272
|
+
title: engineStrict ? "engine-strict is enabled" : "engine-strict is not enabled",
|
|
273
|
+
summary: engineStrict
|
|
274
|
+
? "npm will reject installs on unsupported Node versions."
|
|
275
|
+
: "npm may allow installs even when engines.node does not match.",
|
|
276
|
+
recommendation: engineStrict
|
|
277
|
+
? undefined
|
|
278
|
+
: "Set engine-strict=true in .npmrc when the project relies on a specific supported Node range."
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function addPnpmSecurityChecks(checks, targetPath, lockfiles, packageManager) {
|
|
282
|
+
const usingPnpm = packageManager === "pnpm" || lockfiles.some((lockfile) => lockfile.manager === "pnpm");
|
|
283
|
+
if (!usingPnpm) {
|
|
284
|
+
checks.push({
|
|
285
|
+
id: "pnpm-security-profile",
|
|
286
|
+
category: "security",
|
|
287
|
+
status: "info",
|
|
288
|
+
title: "pnpm security profile is recommended",
|
|
289
|
+
summary: "This project is not using pnpm, so pnpm's dependency-age, build-approval, and trust-policy settings are not available.",
|
|
290
|
+
recommendation: "Evaluate migrating to pnpm, then commit pnpm-lock.yaml and add pnpm-workspace.yaml security settings for minimumReleaseAge, minimumReleaseAgeStrict, trustPolicy, and build approvals."
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
checks.push({
|
|
295
|
+
id: "pnpm-security-profile",
|
|
296
|
+
category: "security",
|
|
297
|
+
status: "pass",
|
|
298
|
+
title: "pnpm is in use",
|
|
299
|
+
summary: "The project uses pnpm or has a pnpm lockfile, so pnpm security settings can be enforced."
|
|
300
|
+
});
|
|
301
|
+
const workspacePath = path.join(targetPath, "pnpm-workspace.yaml");
|
|
302
|
+
const workspace = await readYaml(workspacePath);
|
|
303
|
+
if (!workspace) {
|
|
304
|
+
checks.push({
|
|
305
|
+
id: "pnpm-workspace-security-config",
|
|
306
|
+
category: "security",
|
|
307
|
+
status: "warn",
|
|
308
|
+
title: "pnpm security settings are missing",
|
|
309
|
+
summary: "pnpm-workspace.yaml was not found.",
|
|
310
|
+
recommendation: "Add pnpm-workspace.yaml with minimumReleaseAge: 1440, minimumReleaseAgeStrict: true, trustPolicy: no-downgrade, and build-script approvals."
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
checks.push({
|
|
315
|
+
id: "pnpm-workspace-security-config",
|
|
316
|
+
category: "security",
|
|
317
|
+
status: "pass",
|
|
318
|
+
title: "pnpm workspace settings found",
|
|
319
|
+
summary: "pnpm-workspace.yaml is present and readable.",
|
|
320
|
+
evidence: ["pnpm-workspace.yaml"]
|
|
321
|
+
});
|
|
322
|
+
const minimumReleaseAge = readNumber(workspace.minimumReleaseAge);
|
|
323
|
+
if (minimumReleaseAge === undefined) {
|
|
324
|
+
checks.push({
|
|
325
|
+
id: "pnpm-minimum-release-age",
|
|
326
|
+
category: "security",
|
|
327
|
+
status: "warn",
|
|
328
|
+
title: "pnpm minimumReleaseAge is not explicit",
|
|
329
|
+
summary: "The project does not explicitly delay newly published package versions.",
|
|
330
|
+
recommendation: "Set minimumReleaseAge: 1440 or higher so packages must age before installation."
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
else if (minimumReleaseAge < 1440) {
|
|
334
|
+
checks.push({
|
|
335
|
+
id: "pnpm-minimum-release-age",
|
|
336
|
+
category: "security",
|
|
337
|
+
status: "warn",
|
|
338
|
+
title: "pnpm minimumReleaseAge is low",
|
|
339
|
+
summary: `minimumReleaseAge is ${minimumReleaseAge} minutes.`,
|
|
340
|
+
recommendation: "Use at least 1440 minutes unless the project has a documented reason for a shorter delay."
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
checks.push({
|
|
345
|
+
id: "pnpm-minimum-release-age",
|
|
346
|
+
category: "security",
|
|
347
|
+
status: "pass",
|
|
348
|
+
title: "pnpm minimumReleaseAge is enabled",
|
|
349
|
+
summary: `minimumReleaseAge is ${minimumReleaseAge} minutes.`
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
checks.push({
|
|
353
|
+
id: "pnpm-minimum-release-age-strict",
|
|
354
|
+
category: "security",
|
|
355
|
+
status: workspace.minimumReleaseAgeStrict === true ? "pass" : "warn",
|
|
356
|
+
title: workspace.minimumReleaseAgeStrict === true
|
|
357
|
+
? "pnpm release-age strict mode is enabled"
|
|
358
|
+
: "pnpm release-age strict mode is not explicit",
|
|
359
|
+
summary: workspace.minimumReleaseAgeStrict === true
|
|
360
|
+
? "minimumReleaseAgeStrict is set to true."
|
|
361
|
+
: "Installs may fall back to versions that do not satisfy the release-age policy.",
|
|
362
|
+
recommendation: workspace.minimumReleaseAgeStrict === true
|
|
363
|
+
? undefined
|
|
364
|
+
: "Set minimumReleaseAgeStrict: true to make the release-age policy enforceable."
|
|
365
|
+
});
|
|
366
|
+
checks.push({
|
|
367
|
+
id: "pnpm-trust-policy",
|
|
368
|
+
category: "security",
|
|
369
|
+
status: workspace.trustPolicy === "no-downgrade" ? "pass" : "warn",
|
|
370
|
+
title: workspace.trustPolicy === "no-downgrade" ? "pnpm trustPolicy is enabled" : "pnpm trustPolicy is not enabled",
|
|
371
|
+
summary: workspace.trustPolicy === "no-downgrade"
|
|
372
|
+
? "trustPolicy is set to no-downgrade."
|
|
373
|
+
: "The project is not checking for package trust-level downgrades.",
|
|
374
|
+
recommendation: workspace.trustPolicy === "no-downgrade"
|
|
375
|
+
? undefined
|
|
376
|
+
: "Set trustPolicy: no-downgrade and add narrowly scoped trustPolicyExclude entries only when required."
|
|
377
|
+
});
|
|
378
|
+
const blockExoticSubdeps = workspace.blockExoticSubdeps;
|
|
379
|
+
checks.push({
|
|
380
|
+
id: "pnpm-block-exotic-subdeps",
|
|
381
|
+
category: "security",
|
|
382
|
+
status: blockExoticSubdeps === false ? "fail" : blockExoticSubdeps === true ? "pass" : "info",
|
|
383
|
+
title: blockExoticSubdeps === false
|
|
384
|
+
? "pnpm blockExoticSubdeps is disabled"
|
|
385
|
+
: blockExoticSubdeps === true
|
|
386
|
+
? "pnpm blockExoticSubdeps is enabled"
|
|
387
|
+
: "pnpm blockExoticSubdeps uses the pnpm default",
|
|
388
|
+
summary: blockExoticSubdeps === false
|
|
389
|
+
? "Transitive dependencies may use exotic sources such as git repositories or tarball URLs."
|
|
390
|
+
: blockExoticSubdeps === true
|
|
391
|
+
? "Transitive dependencies are blocked from using exotic sources."
|
|
392
|
+
: "Modern pnpm defaults block exotic transitive dependency sources.",
|
|
393
|
+
recommendation: blockExoticSubdeps === false ? "Set blockExoticSubdeps: true." : undefined
|
|
394
|
+
});
|
|
395
|
+
const allowBuilds = asObject(workspace.allowBuilds);
|
|
396
|
+
const onlyBuiltDependencies = Array.isArray(workspace.onlyBuiltDependencies)
|
|
397
|
+
? workspace.onlyBuiltDependencies
|
|
398
|
+
: undefined;
|
|
399
|
+
const ignoredBuiltDependencies = Array.isArray(workspace.ignoredBuiltDependencies)
|
|
400
|
+
? workspace.ignoredBuiltDependencies
|
|
401
|
+
: undefined;
|
|
402
|
+
const hasBuildPolicy = Boolean(allowBuilds && Object.keys(allowBuilds).length > 0) ||
|
|
403
|
+
Boolean(onlyBuiltDependencies?.length) ||
|
|
404
|
+
Boolean(ignoredBuiltDependencies?.length);
|
|
405
|
+
checks.push({
|
|
406
|
+
id: "pnpm-build-approvals",
|
|
407
|
+
category: "security",
|
|
408
|
+
status: hasBuildPolicy ? "pass" : "warn",
|
|
409
|
+
title: hasBuildPolicy ? "pnpm build approvals are configured" : "pnpm build approvals are not configured",
|
|
410
|
+
summary: hasBuildPolicy
|
|
411
|
+
? "The workspace has an allow/deny policy for dependency install scripts."
|
|
412
|
+
: "No allowBuilds, onlyBuiltDependencies, or ignoredBuiltDependencies policy was found.",
|
|
413
|
+
recommendation: hasBuildPolicy
|
|
414
|
+
? undefined
|
|
415
|
+
: "Run pnpm approve-builds and commit the resulting build-script allow/deny policy."
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function addScriptChecks(checks, packageJson) {
|
|
419
|
+
const scripts = asObject(packageJson.scripts) ?? {};
|
|
420
|
+
const hasTest = Boolean(readString(scripts.test)) && readString(scripts.test) !== 'echo "Error: no test specified" && exit 1';
|
|
421
|
+
const hasLint = Boolean(readString(scripts.lint));
|
|
422
|
+
const hasTypecheck = Boolean(readString(scripts.typecheck) ?? readString(scripts["type-check"]) ?? readString(scripts.tsc));
|
|
423
|
+
checks.push({
|
|
424
|
+
id: "test-script",
|
|
425
|
+
category: "quality",
|
|
426
|
+
status: hasTest ? "pass" : "warn",
|
|
427
|
+
title: hasTest ? "Test script is defined" : "Test script is missing",
|
|
428
|
+
summary: hasTest ? `test runs: ${scripts.test}` : "package.json does not define a useful test script.",
|
|
429
|
+
recommendation: hasTest ? undefined : "Add a deterministic test command that CI can run."
|
|
430
|
+
});
|
|
431
|
+
checks.push({
|
|
432
|
+
id: "lint-script",
|
|
433
|
+
category: "quality",
|
|
434
|
+
status: hasLint ? "pass" : "warn",
|
|
435
|
+
title: hasLint ? "Lint script is defined" : "Lint script is missing",
|
|
436
|
+
summary: hasLint ? `lint runs: ${scripts.lint}` : "package.json does not define a lint script.",
|
|
437
|
+
recommendation: hasLint ? undefined : "Add linting so code-style and likely bug checks run before review."
|
|
438
|
+
});
|
|
439
|
+
checks.push({
|
|
440
|
+
id: "typecheck-script",
|
|
441
|
+
category: "quality",
|
|
442
|
+
status: hasTypecheck ? "pass" : "info",
|
|
443
|
+
title: hasTypecheck ? "Typecheck script is defined" : "Typecheck script is not defined",
|
|
444
|
+
summary: hasTypecheck ? "A type checking script is available." : "No dedicated type checking script was detected.",
|
|
445
|
+
recommendation: hasTypecheck ? undefined : "For TypeScript projects, add a typecheck script such as tsc --noEmit."
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
async function addAutomationChecks(checks, targetPath) {
|
|
449
|
+
const hasGithubActions = await exists(path.join(targetPath, ".github", "workflows"));
|
|
450
|
+
const hasDependabot = (await exists(path.join(targetPath, ".github", "dependabot.yml"))) ||
|
|
451
|
+
(await exists(path.join(targetPath, ".github", "dependabot.yaml")));
|
|
452
|
+
const hasRenovate = (await exists(path.join(targetPath, "renovate.json"))) ||
|
|
453
|
+
(await exists(path.join(targetPath, ".github", "renovate.json")));
|
|
454
|
+
checks.push({
|
|
455
|
+
id: "ci-present",
|
|
456
|
+
category: "automation",
|
|
457
|
+
status: hasGithubActions ? "pass" : "warn",
|
|
458
|
+
title: hasGithubActions ? "CI workflow folder found" : "CI workflow folder is missing",
|
|
459
|
+
summary: hasGithubActions ? ".github/workflows exists." : "No GitHub Actions workflow folder was found.",
|
|
460
|
+
recommendation: hasGithubActions
|
|
461
|
+
? undefined
|
|
462
|
+
: "Add CI that runs install, lint, tests, typecheck, and audit checks on pull requests."
|
|
463
|
+
});
|
|
464
|
+
checks.push({
|
|
465
|
+
id: "dependency-update-automation",
|
|
466
|
+
category: "maintenance",
|
|
467
|
+
status: hasDependabot || hasRenovate ? "pass" : "warn",
|
|
468
|
+
title: hasDependabot || hasRenovate ? "Dependency update automation found" : "Dependency update automation is missing",
|
|
469
|
+
summary: hasDependabot
|
|
470
|
+
? "Dependabot config found."
|
|
471
|
+
: hasRenovate
|
|
472
|
+
? "Renovate config found."
|
|
473
|
+
: "No Dependabot or Renovate config was found.",
|
|
474
|
+
recommendation: hasDependabot || hasRenovate
|
|
475
|
+
? undefined
|
|
476
|
+
: "Add Renovate or Dependabot so dependency and lockfile updates are reviewed continuously."
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
async function suggestTools(targetPath, packageJson) {
|
|
480
|
+
const allDeps = new Set(getDirectDependencyNames(packageJson));
|
|
481
|
+
const scripts = asObject(packageJson.scripts) ?? {};
|
|
482
|
+
const packageManager = readString(packageJson.packageManager)?.split("@")[0];
|
|
483
|
+
const hasTsconfig = await exists(path.join(targetPath, "tsconfig.json"));
|
|
484
|
+
const hasEslintConfig = await anyExists(targetPath, [
|
|
485
|
+
"eslint.config.js",
|
|
486
|
+
"eslint.config.mjs",
|
|
487
|
+
".eslintrc",
|
|
488
|
+
".eslintrc.json",
|
|
489
|
+
".eslintrc.js"
|
|
490
|
+
]);
|
|
491
|
+
const hasPrettierConfig = await anyExists(targetPath, [
|
|
492
|
+
".prettierrc",
|
|
493
|
+
".prettierrc.json",
|
|
494
|
+
".prettierrc.js",
|
|
495
|
+
"prettier.config.js",
|
|
496
|
+
"prettier.config.mjs"
|
|
497
|
+
]);
|
|
498
|
+
const hasVitest = allDeps.has("vitest") || /vitest/.test(readString(scripts.test) ?? "");
|
|
499
|
+
const hasJest = allDeps.has("jest") || /jest/.test(readString(scripts.test) ?? "");
|
|
500
|
+
const hasLockfileLint = allDeps.has("lockfile-lint");
|
|
501
|
+
const hasLintStaged = allDeps.has("lint-staged");
|
|
502
|
+
const hasHusky = allDeps.has("husky") || (await exists(path.join(targetPath, ".husky")));
|
|
503
|
+
const hasPnpm = packageManager === "pnpm" || (await exists(path.join(targetPath, "pnpm-lock.yaml")));
|
|
504
|
+
return [
|
|
505
|
+
{
|
|
506
|
+
name: "pnpm",
|
|
507
|
+
purpose: "Package manager with lockfile-focused installs, strict dependency layout, release-age gates, trust policy checks, and dependency build approvals.",
|
|
508
|
+
install: "corepack enable && corepack use pnpm@latest",
|
|
509
|
+
configHint: "Commit pnpm-lock.yaml and add pnpm-workspace.yaml with minimumReleaseAge, minimumReleaseAgeStrict, trustPolicy, and build approvals.",
|
|
510
|
+
priority: "high",
|
|
511
|
+
present: hasPnpm
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "TypeScript",
|
|
515
|
+
purpose: "Static typing and API drift detection.",
|
|
516
|
+
install: "npm install -D typescript @types/node",
|
|
517
|
+
configHint: "Add tsconfig.json and a typecheck script.",
|
|
518
|
+
priority: "high",
|
|
519
|
+
present: hasTsconfig || allDeps.has("typescript")
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "ESLint",
|
|
523
|
+
purpose: "Static analysis for common JavaScript and TypeScript defects.",
|
|
524
|
+
install: "npm install -D eslint",
|
|
525
|
+
configHint: "Use eslint.config.js and wire npm run lint into CI.",
|
|
526
|
+
priority: "high",
|
|
527
|
+
present: hasEslintConfig || allDeps.has("eslint")
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: "Prettier",
|
|
531
|
+
purpose: "Consistent formatting with low review noise.",
|
|
532
|
+
install: "npm install -D prettier",
|
|
533
|
+
configHint: "Add a format:check script for CI.",
|
|
534
|
+
priority: "medium",
|
|
535
|
+
present: hasPrettierConfig || allDeps.has("prettier")
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
name: "Vitest or Jest",
|
|
539
|
+
purpose: "Fast deterministic unit and integration tests.",
|
|
540
|
+
install: "npm install -D vitest",
|
|
541
|
+
configHint: "Add a test script that exits non-zero on failure.",
|
|
542
|
+
priority: "high",
|
|
543
|
+
present: hasVitest || hasJest
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "lockfile-lint",
|
|
547
|
+
purpose: "Validate lockfile hosts, schemes, and integrity metadata.",
|
|
548
|
+
install: "npm install -D lockfile-lint",
|
|
549
|
+
configHint: "Run it in CI against the committed lockfile.",
|
|
550
|
+
priority: "medium",
|
|
551
|
+
present: hasLockfileLint
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
name: "lint-staged + Husky",
|
|
555
|
+
purpose: "Run cheap local checks only on files being committed.",
|
|
556
|
+
install: "npm install -D lint-staged husky",
|
|
557
|
+
configHint: "Keep hooks fast and leave full test suites to CI.",
|
|
558
|
+
priority: "low",
|
|
559
|
+
present: hasLintStaged && hasHusky
|
|
560
|
+
}
|
|
561
|
+
];
|
|
562
|
+
}
|
|
563
|
+
function toolChecks(tools) {
|
|
564
|
+
return tools.map((tool) => ({
|
|
565
|
+
id: `tool-${tool.name
|
|
566
|
+
.toLowerCase()
|
|
567
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
568
|
+
.replace(/(^-|-$)/g, "")}`,
|
|
569
|
+
category: "quality",
|
|
570
|
+
status: tool.present ? "pass" : tool.priority === "high" ? "warn" : "info",
|
|
571
|
+
title: tool.present ? `${tool.name} is present` : `${tool.name} is recommended`,
|
|
572
|
+
summary: tool.present ? `${tool.name} appears to be configured or installed.` : tool.purpose,
|
|
573
|
+
recommendation: tool.present ? undefined : `${tool.install}. ${tool.configHint ?? ""}`.trim()
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
async function collectRegistryMetadata(packageJson) {
|
|
577
|
+
const directDeps = getDirectDependencyNames(packageJson);
|
|
578
|
+
const names = [...new Set(directDeps)].slice(0, 60);
|
|
579
|
+
const results = await Promise.all(names.map((name) => fetchPackageMetadata(name)));
|
|
580
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
581
|
+
}
|
|
582
|
+
function addRegistryChecks(checks, packages, now) {
|
|
583
|
+
if (packages.length === 0) {
|
|
584
|
+
checks.push({
|
|
585
|
+
id: "registry-metadata",
|
|
586
|
+
category: "maintenance",
|
|
587
|
+
status: "info",
|
|
588
|
+
title: "Registry metadata not checked",
|
|
589
|
+
summary: "Registry checks were skipped or no dependencies were found."
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const deprecated = packages.filter((pkg) => pkg.deprecated);
|
|
594
|
+
checks.push({
|
|
595
|
+
id: "deprecated-packages",
|
|
596
|
+
category: "maintenance",
|
|
597
|
+
status: deprecated.length > 0 ? "fail" : "pass",
|
|
598
|
+
title: deprecated.length > 0 ? "Deprecated packages detected" : "No deprecated latest packages detected",
|
|
599
|
+
summary: deprecated.length > 0
|
|
600
|
+
? `${deprecated.length} direct package latest versions are marked deprecated.`
|
|
601
|
+
: "The npm registry did not report deprecation on the latest version of direct packages.",
|
|
602
|
+
recommendation: deprecated.length > 0 ? "Replace deprecated packages or move to maintained alternatives." : undefined,
|
|
603
|
+
evidence: deprecated.map((pkg) => `${pkg.name}: ${pkg.deprecated}`)
|
|
604
|
+
});
|
|
605
|
+
const stale = packages.filter((pkg) => {
|
|
606
|
+
if (!pkg.modified)
|
|
607
|
+
return false;
|
|
608
|
+
const ageMs = now.getTime() - new Date(pkg.modified).getTime();
|
|
609
|
+
return ageMs > 730 * 86_400_000;
|
|
610
|
+
});
|
|
611
|
+
checks.push({
|
|
612
|
+
id: "stale-package-activity",
|
|
613
|
+
category: "maintenance",
|
|
614
|
+
status: stale.length > 0 ? "warn" : "pass",
|
|
615
|
+
title: stale.length > 0 ? "Packages with stale registry activity" : "Direct package metadata looks active",
|
|
616
|
+
summary: stale.length > 0
|
|
617
|
+
? `${stale.length} direct packages have not been modified in over two years.`
|
|
618
|
+
: "Direct package registry metadata has recent modification dates.",
|
|
619
|
+
recommendation: stale.length > 0
|
|
620
|
+
? "Review stale packages for maintenance risk before relying on them in critical paths."
|
|
621
|
+
: undefined,
|
|
622
|
+
evidence: stale.map((pkg) => `${pkg.name}: ${pkg.modified}`)
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
async function detectLockfiles(targetPath) {
|
|
626
|
+
const found = [];
|
|
627
|
+
for (const lockfile of LOCKFILES) {
|
|
628
|
+
const fullPath = path.join(targetPath, lockfile.file);
|
|
629
|
+
if (!(await exists(fullPath))) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (lockfile.file === "package-lock.json" || lockfile.file === "npm-shrinkwrap.json") {
|
|
633
|
+
const json = await readJson(fullPath);
|
|
634
|
+
found.push({
|
|
635
|
+
...lockfile,
|
|
636
|
+
version: readString(json?.lockfileVersion) ??
|
|
637
|
+
(typeof json?.lockfileVersion === "number" ? String(json.lockfileVersion) : undefined)
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
else if (lockfile.file === "pnpm-lock.yaml") {
|
|
641
|
+
const text = await readText(fullPath);
|
|
642
|
+
const parsed = text ? YAML.parse(text) : undefined;
|
|
643
|
+
found.push({
|
|
644
|
+
...lockfile,
|
|
645
|
+
version: readString(parsed?.lockfileVersion) ??
|
|
646
|
+
(typeof parsed?.lockfileVersion === "number" ? String(parsed.lockfileVersion) : undefined)
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
found.push(lockfile);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return found;
|
|
654
|
+
}
|
|
655
|
+
function buildReport(targetPath, checks, tools, packages, now, packageName, packageManager) {
|
|
656
|
+
const pass = checks.filter((check) => check.status === "pass").length;
|
|
657
|
+
const warn = checks.filter((check) => check.status === "warn").length;
|
|
658
|
+
const fail = checks.filter((check) => check.status === "fail").length;
|
|
659
|
+
const info = checks.filter((check) => check.status === "info").length;
|
|
660
|
+
const scored = pass + warn + fail;
|
|
661
|
+
const value = scored === 0 ? 0 : Math.max(0, Math.round(((pass + warn * 0.45) / scored) * 100));
|
|
662
|
+
return {
|
|
663
|
+
targetPath,
|
|
664
|
+
generatedAt: now.toISOString(),
|
|
665
|
+
packageName,
|
|
666
|
+
packageManager,
|
|
667
|
+
score: { value, pass, warn, fail, info },
|
|
668
|
+
checks,
|
|
669
|
+
tools,
|
|
670
|
+
packages
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function getDirectDependencyNames(packageJson) {
|
|
674
|
+
const names = new Set();
|
|
675
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
676
|
+
const dependencies = asObject(packageJson[section]);
|
|
677
|
+
if (!dependencies)
|
|
678
|
+
continue;
|
|
679
|
+
for (const name of Object.keys(dependencies)) {
|
|
680
|
+
names.add(name);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return [...names].sort();
|
|
684
|
+
}
|
|
685
|
+
async function readJson(filePath) {
|
|
686
|
+
const text = await readText(filePath);
|
|
687
|
+
if (!text)
|
|
688
|
+
return undefined;
|
|
689
|
+
try {
|
|
690
|
+
return JSON.parse(text);
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async function readYaml(filePath) {
|
|
697
|
+
const text = await readText(filePath);
|
|
698
|
+
if (!text)
|
|
699
|
+
return undefined;
|
|
700
|
+
try {
|
|
701
|
+
return YAML.parse(text);
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function readText(filePath) {
|
|
708
|
+
try {
|
|
709
|
+
return await readFile(filePath, "utf8");
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function exists(filePath) {
|
|
716
|
+
try {
|
|
717
|
+
await access(filePath);
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function anyExists(targetPath, filenames) {
|
|
725
|
+
for (const filename of filenames) {
|
|
726
|
+
if (await exists(path.join(targetPath, filename))) {
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
function asObject(value) {
|
|
733
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
734
|
+
return value;
|
|
735
|
+
}
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
function readString(value) {
|
|
739
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
740
|
+
return value;
|
|
741
|
+
}
|
|
742
|
+
if (typeof value === "number") {
|
|
743
|
+
return String(value);
|
|
744
|
+
}
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
function readNumber(value) {
|
|
748
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
749
|
+
return value;
|
|
750
|
+
}
|
|
751
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
752
|
+
const parsed = Number(value);
|
|
753
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
export async function isDirectory(filePath) {
|
|
758
|
+
try {
|
|
759
|
+
return (await stat(filePath)).isDirectory();
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
//# sourceMappingURL=index.js.map
|