@dependably/npm-check 1.9.0 → 1.10.1
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 +47 -0
- package/bin/cli.js +93 -1
- package/package.json +20 -3
- package/src/audit-config.js +98 -68
- package/src/audit.js +198 -58
- package/src/exceptions.js +82 -66
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +151 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +37 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +493 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/sourcescan.js +221 -0
- package/src/facts/specifier.js +89 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +579 -0
- package/src/facts/version.js +16 -0
- package/src/facts/workspace.js +278 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +12 -6
- package/src/schema.js +54 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// src/facts/workspace.js
|
|
2
|
+
// What the tree under `srcDir` declares about itself: the first-party package
|
|
3
|
+
// names (every package.json's `name`), the dev/runtime scope each manifest
|
|
4
|
+
// gives its dependencies ("runtime anywhere wins" across manifests, with
|
|
5
|
+
// `devDeclaredBy` tracking WHICH manifest made a dev claim), the
|
|
6
|
+
// tsconfig/jsconfig `paths` alias bases scoped to the subtree of the config
|
|
7
|
+
// that declares them, and the first-party source files themselves -- bounded
|
|
8
|
+
// by `.gitignore`, never by a directory name -- everything the scan and the
|
|
9
|
+
// module-graph walk take as their starting point.
|
|
10
|
+
//
|
|
11
|
+
// Ported from sbom-reach's `packages/analyzer-npm/src/workspace.ts` as it
|
|
12
|
+
// existed after commit 95f2b94 ("fix(npm,pypi): bound the source scan by
|
|
13
|
+
// .gitignore, never by a directory name", GitLab #31).
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
16
|
+
import fg from 'fast-glob';
|
|
17
|
+
import { aliasBaseFromPathsKey } from './specifier.js';
|
|
18
|
+
import { filterGitignored, loadGitignores, outputDirScannedDiagnostic } from './sourcescan.js';
|
|
19
|
+
import { loadTypeScript } from './ts.js';
|
|
20
|
+
|
|
21
|
+
/** @typedef {import('./types.d.ts').DepScope} DepScope */
|
|
22
|
+
/** @typedef {import('./types.d.ts').Workspace} Workspace */
|
|
23
|
+
/** @typedef {import('./types.d.ts').AliasScope} AliasScope */
|
|
24
|
+
/** @typedef {import('./types.d.ts').AliasLayer} AliasLayer */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The only directories excluded from the source scan BY NAME.
|
|
28
|
+
*
|
|
29
|
+
* Both are universal rather than conventional. `.git` holds no source. And
|
|
30
|
+
* `node_modules` is not a naming convention at all -- it is the location the
|
|
31
|
+
* Node resolver defines, and its contents are a DEPENDENCY's own imports,
|
|
32
|
+
* not first-party code; counting them would make every transitive
|
|
33
|
+
* dependency look first-party-imported. The module graph walks it
|
|
34
|
+
* deliberately (`modulegraph.js`), which is a different pass with a
|
|
35
|
+
* different question.
|
|
36
|
+
*
|
|
37
|
+
* Everything else that used to live here -- `dist`, `build`, `out`,
|
|
38
|
+
* `coverage`, `.next`, `.turbo`, `vendor` -- was a GUESS from a directory
|
|
39
|
+
* name that the file it excluded was generated output. In a real tree those
|
|
40
|
+
* names are often source. `.gitignore`, loaded below, is the real authority
|
|
41
|
+
* on what is generated: a project that builds into `dist/` gitignores
|
|
42
|
+
* `dist/`.
|
|
43
|
+
*
|
|
44
|
+
* Note that dot-directories stay excluded regardless, via the globber's
|
|
45
|
+
* `dot: false` -- a hidden-directory convention, not a guess about content.
|
|
46
|
+
*/
|
|
47
|
+
const IGNORE_DIRS = ['**/node_modules/**', '**/.git/**'];
|
|
48
|
+
|
|
49
|
+
const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,svelte}';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} srcDir absolute path of the tree to describe
|
|
53
|
+
* @returns {Workspace}
|
|
54
|
+
*/
|
|
55
|
+
export function discoverWorkspace(srcDir) {
|
|
56
|
+
const ts = loadTypeScript();
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
|
|
60
|
+
const gitignores = loadGitignores(srcDir, IGNORE_DIRS);
|
|
61
|
+
/** @param {string[]} paths @returns {string[]} */
|
|
62
|
+
const filterIgnored = (paths) => filterGitignored(srcDir, gitignores, paths);
|
|
63
|
+
|
|
64
|
+
const manifestPaths = filterIgnored(
|
|
65
|
+
fg.sync('**/package.json', {
|
|
66
|
+
cwd: srcDir,
|
|
67
|
+
absolute: true,
|
|
68
|
+
ignore: IGNORE_DIRS,
|
|
69
|
+
dot: false,
|
|
70
|
+
followSymbolicLinks: false
|
|
71
|
+
})
|
|
72
|
+
).sort();
|
|
73
|
+
|
|
74
|
+
/** @type {Set<string>} */
|
|
75
|
+
const firstPartyNames = new Set();
|
|
76
|
+
/** @type {Map<string, DepScope>} */
|
|
77
|
+
const depScopes = new Map();
|
|
78
|
+
/** name -> manifests declaring it dev; pruned below for anything declared runtime. */
|
|
79
|
+
/** @type {Map<string, string[]>} */
|
|
80
|
+
const devDeclaredBy = new Map();
|
|
81
|
+
|
|
82
|
+
for (const path of manifestPaths) {
|
|
83
|
+
/** @type {Record<string, unknown>} */
|
|
84
|
+
let json;
|
|
85
|
+
try {
|
|
86
|
+
json = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(path, 'utf8')));
|
|
87
|
+
} catch {
|
|
88
|
+
diagnostics.push(`unparseable package.json at ${relative(srcDir, path)}; skipped`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (typeof json.name === 'string') firstPartyNames.add(json.name.toLowerCase());
|
|
92
|
+
|
|
93
|
+
const runtimeSections = ['dependencies', 'optionalDependencies', 'peerDependencies'];
|
|
94
|
+
for (const section of runtimeSections) {
|
|
95
|
+
for (const name of depNames(json[section])) depScopes.set(name, 'runtime');
|
|
96
|
+
}
|
|
97
|
+
for (const name of depNames(json.devDependencies)) {
|
|
98
|
+
if (depScopes.get(name) !== 'runtime') depScopes.set(name, 'dev');
|
|
99
|
+
devDeclaredBy.set(name, [...(devDeclaredBy.get(name) ?? []), relative(srcDir, path).split(sep).join('/')]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// "Runtime anywhere wins" already decided `depScopes`; drop the dev trail
|
|
103
|
+
// for anything that ended up runtime, so the map only ever describes a
|
|
104
|
+
// live claim.
|
|
105
|
+
const runtimeDeclared = [...devDeclaredBy.keys()].filter((n) => depScopes.get(n) !== 'dev');
|
|
106
|
+
for (const name of runtimeDeclared) devDeclaredBy.delete(name);
|
|
107
|
+
|
|
108
|
+
/** @type {Set<string>} */
|
|
109
|
+
const aliasPrefixes = new Set();
|
|
110
|
+
/** One entry per config file: the directory it governs, and what it declares. */
|
|
111
|
+
/** @type {AliasLayer[]} */
|
|
112
|
+
const aliasLayers = [];
|
|
113
|
+
const aliasConfigPaths = filterIgnored(
|
|
114
|
+
fg.sync(['**/tsconfig*.json', '**/jsconfig*.json'], {
|
|
115
|
+
cwd: srcDir,
|
|
116
|
+
absolute: true,
|
|
117
|
+
ignore: IGNORE_DIRS,
|
|
118
|
+
dot: false,
|
|
119
|
+
followSymbolicLinks: false
|
|
120
|
+
})
|
|
121
|
+
).sort();
|
|
122
|
+
for (const path of aliasConfigPaths) {
|
|
123
|
+
const read = ts.readConfigFile(path, (p) => readFileSync(p, 'utf8'));
|
|
124
|
+
if (read.error) {
|
|
125
|
+
diagnostics.push(`unparseable tsconfig/jsconfig at ${relative(srcDir, path)}; aliases from it ignored`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Resolve `extends` so inherited paths are honored too.
|
|
129
|
+
/** @type {Record<string, unknown>} */
|
|
130
|
+
let config = /** @type {Record<string, unknown>} */ (read.config);
|
|
131
|
+
const visited = new Set([path]);
|
|
132
|
+
let current = path;
|
|
133
|
+
while (typeof config.extends === 'string') {
|
|
134
|
+
const parentPath = resolveExtends(config.extends, dirname(current));
|
|
135
|
+
if (!parentPath || visited.has(parentPath)) break;
|
|
136
|
+
visited.add(parentPath);
|
|
137
|
+
const parent = ts.readConfigFile(parentPath, (p) => readFileSync(p, 'utf8'));
|
|
138
|
+
if (parent.error) break;
|
|
139
|
+
const parentConfig = /** @type {Record<string, unknown>} */ (parent.config);
|
|
140
|
+
config = {
|
|
141
|
+
...parentConfig,
|
|
142
|
+
...config,
|
|
143
|
+
compilerOptions: {
|
|
144
|
+
.../** @type {object | undefined} */ (parentConfig.compilerOptions),
|
|
145
|
+
.../** @type {object | undefined} */ (config.compilerOptions)
|
|
146
|
+
},
|
|
147
|
+
extends: /** @type {{extends?: unknown}} */ (parentConfig).extends
|
|
148
|
+
};
|
|
149
|
+
current = parentPath;
|
|
150
|
+
}
|
|
151
|
+
const compilerOptions = /** @type {{ paths?: Record<string, unknown> } | undefined} */ (config.compilerOptions);
|
|
152
|
+
const paths = compilerOptions?.paths;
|
|
153
|
+
if (paths) {
|
|
154
|
+
/** @type {Set<string>} */
|
|
155
|
+
const prefixes = new Set();
|
|
156
|
+
for (const key of Object.keys(paths)) {
|
|
157
|
+
const base = aliasBaseFromPathsKey(key);
|
|
158
|
+
prefixes.add(base);
|
|
159
|
+
aliasPrefixes.add(base);
|
|
160
|
+
}
|
|
161
|
+
// Scoped to the directory of the config that was FOUND, not of
|
|
162
|
+
// whatever it `extends`: a base config supplies the paths, the
|
|
163
|
+
// project that extends it supplies the files they apply to -- as tsc
|
|
164
|
+
// does.
|
|
165
|
+
//
|
|
166
|
+
// The approximation is CONTAINMENT, and tsc's real answer is
|
|
167
|
+
// `include` / `files` / `rootDir`. A config that reaches outside its
|
|
168
|
+
// own directory governs those files in tsc and not here, so their
|
|
169
|
+
// imports keep naming packages and a legitimate alias is dropped.
|
|
170
|
+
// That over-reports use -- the loud direction (invariant 1), and the
|
|
171
|
+
// opposite of the silent suppression this scoping exists to stop.
|
|
172
|
+
if (prefixes.size > 0) aliasLayers.push({ dir: dirname(path), prefixes: [...prefixes] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const aliasScope = buildAliasScope(aliasLayers);
|
|
176
|
+
|
|
177
|
+
const sourceFiles = filterIgnored(
|
|
178
|
+
fg.sync(SOURCE_GLOB, {
|
|
179
|
+
cwd: srcDir,
|
|
180
|
+
absolute: true,
|
|
181
|
+
ignore: IGNORE_DIRS,
|
|
182
|
+
dot: false,
|
|
183
|
+
followSymbolicLinks: false
|
|
184
|
+
})
|
|
185
|
+
).sort();
|
|
186
|
+
|
|
187
|
+
// Say it out loud when a directory whose NAME suggests generated output
|
|
188
|
+
// was scanned anyway, because nothing ignored it. A note, not a warning --
|
|
189
|
+
// see `outputDirScannedDiagnostic` for why.
|
|
190
|
+
const outputDirs = outputDirScannedDiagnostic(sourceFiles.map((f) => relative(srcDir, f).split(sep).join('/')));
|
|
191
|
+
if (outputDirs) diagnostics.push(outputDirs);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
firstPartyNames,
|
|
195
|
+
depScopes,
|
|
196
|
+
aliasPrefixes,
|
|
197
|
+
aliasScope,
|
|
198
|
+
aliasLayers,
|
|
199
|
+
devDeclaredBy,
|
|
200
|
+
sourceFiles,
|
|
201
|
+
diagnostics
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A file is governed by every config at or above its own directory. Nothing
|
|
207
|
+
* is matched by NAME here: a `vendor/lib/tsconfig.json` is not
|
|
208
|
+
* special-cased, it simply governs `vendor/lib/`, and the file in `src/`
|
|
209
|
+
* that a global set used to silence is outside it.
|
|
210
|
+
*
|
|
211
|
+
* Memoized per directory -- a tree with one root tsconfig (the common case)
|
|
212
|
+
* does one prefix walk per directory and then answers from the cache.
|
|
213
|
+
*
|
|
214
|
+
* @param {AliasLayer[]} layers directories are ABSOLUTE paths
|
|
215
|
+
* @returns {AliasScope}
|
|
216
|
+
*/
|
|
217
|
+
function buildAliasScope(layers) {
|
|
218
|
+
/** @type {ReadonlySet<string>} */
|
|
219
|
+
const empty = new Set();
|
|
220
|
+
if (layers.length === 0) return { for: () => empty };
|
|
221
|
+
/** @type {Map<string, ReadonlySet<string>>} */
|
|
222
|
+
const cache = new Map();
|
|
223
|
+
return {
|
|
224
|
+
for(file) {
|
|
225
|
+
const dir = dirname(file);
|
|
226
|
+
const cached = cache.get(dir);
|
|
227
|
+
if (cached !== undefined) return cached;
|
|
228
|
+
/** @type {Set<string> | undefined} */
|
|
229
|
+
let hits;
|
|
230
|
+
for (const layer of layers) {
|
|
231
|
+
if (dir !== layer.dir && !dir.startsWith(layer.dir.endsWith(sep) ? layer.dir : `${layer.dir}${sep}`)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
hits ??= new Set();
|
|
235
|
+
for (const prefix of layer.prefixes) hits.add(prefix);
|
|
236
|
+
}
|
|
237
|
+
const result = hits ?? empty;
|
|
238
|
+
cache.set(dir, result);
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Is `rel` (a `/`-joined path) inside the directory of the manifest at `manifestRel`?
|
|
246
|
+
* @param {string} manifestRel
|
|
247
|
+
* @param {string} rel
|
|
248
|
+
* @returns {boolean}
|
|
249
|
+
*/
|
|
250
|
+
export function governedByManifest(manifestRel, rel) {
|
|
251
|
+
const slash = manifestRel.lastIndexOf('/');
|
|
252
|
+
if (slash === -1) return true; // root manifest governs the whole tree
|
|
253
|
+
return rel.startsWith(`${manifestRel.slice(0, slash)}/`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {unknown} section
|
|
258
|
+
* @returns {string[]}
|
|
259
|
+
*/
|
|
260
|
+
function depNames(section) {
|
|
261
|
+
if (section === null || typeof section !== 'object') return [];
|
|
262
|
+
return Object.keys(/** @type {Record<string, unknown>} */ (section)).map((n) => n.toLowerCase());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {string} spec
|
|
267
|
+
* @param {string} fromDir
|
|
268
|
+
* @returns {string | undefined}
|
|
269
|
+
*/
|
|
270
|
+
function resolveExtends(spec, fromDir) {
|
|
271
|
+
if (spec.startsWith('.') || spec.startsWith('/')) {
|
|
272
|
+
const p = join(fromDir, spec);
|
|
273
|
+
return p.endsWith('.json') ? p : `${p}.json`;
|
|
274
|
+
}
|
|
275
|
+
// Package-based extends (e.g. @tsconfig/node20) would need module resolution;
|
|
276
|
+
// out of scope for alias collection.
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
package/src/npmrc-validator.js
CHANGED
|
@@ -59,7 +59,8 @@ const KNOWN_KEYS = new Set([
|
|
|
59
59
|
'fetch-retry-maxtimeout', 'fetch-timeout', 'access', 'tag', 'lockfile-version',
|
|
60
60
|
'omit', 'include', 'ignore-scripts', 'foreground-scripts', 'node-options',
|
|
61
61
|
'progress', 'prefer-offline', 'prefer-online', 'offline', 'global', 'unsafe-perm',
|
|
62
|
-
'update-notifier', 'user-agent', 'maxsockets', 'before', 'workspaces', 'workspace'
|
|
62
|
+
'update-notifier', 'user-agent', 'maxsockets', 'before', 'workspaces', 'workspace',
|
|
63
|
+
'min-release-age'
|
|
63
64
|
]);
|
|
64
65
|
|
|
65
66
|
// Plaintext-credential keys: bare `_auth`/`_authtoken`/`_password`, or the
|
|
@@ -22,6 +22,7 @@ export class PnpmWorkspaceValidationError extends Error {
|
|
|
22
22
|
|
|
23
23
|
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
24
24
|
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
25
|
+
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
25
26
|
|
|
26
27
|
// Recognized top-level keys and the type each must have. Generous (warn-only on
|
|
27
28
|
// unknowns) rather than exhaustive — pnpm adds settings often.
|
|
@@ -52,12 +53,17 @@ const KNOWN_KEYS = {
|
|
|
52
53
|
virtualStoreDir: (v) => typeof v === 'string',
|
|
53
54
|
preferWorkspacePackages: (v) => typeof v === 'boolean',
|
|
54
55
|
linkWorkspacePackages: (v) => typeof v === 'boolean' || typeof v === 'string',
|
|
55
|
-
saveWorkspaceProtocol: (v) => typeof v === 'boolean' || typeof v === 'string'
|
|
56
|
+
saveWorkspaceProtocol: (v) => typeof v === 'boolean' || typeof v === 'string',
|
|
57
|
+
// Supply-chain cooldown (pnpm >= 10.16). NOTE the unit is MINUTES here, while
|
|
58
|
+
// npm's `.npmrc` `min-release-age` is DAYS — see the min-release-age audit rule.
|
|
59
|
+
minimumReleaseAge: isNumber,
|
|
60
|
+
minimumReleaseAgeExclude: isStringArray
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
const TYPE_LABEL = new Map([
|
|
59
64
|
[isStringArray, 'an array of strings'],
|
|
60
|
-
[isPlainObject, 'an object']
|
|
65
|
+
[isPlainObject, 'an object'],
|
|
66
|
+
[isNumber, 'a number']
|
|
61
67
|
]);
|
|
62
68
|
function expectedLabel(validator) {
|
|
63
69
|
return TYPE_LABEL.get(validator) || 'the correct type';
|
package/src/report.js
CHANGED
|
@@ -18,7 +18,7 @@ import { buildEnvelope } from './schema.js';
|
|
|
18
18
|
// pin just like npm's, and the pinned-versions rule is npm+pnpm flavored). The
|
|
19
19
|
// npm-lockfile-shape sections (and license, pending a `.pnpm` store walk) are
|
|
20
20
|
// marked N/A rather than rendered as a misleading pass.
|
|
21
|
-
const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned', 'unresolved']);
|
|
21
|
+
const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned', 'unresolved', 'release-age']);
|
|
22
22
|
// The pnpm-config section has no meaning for an npm lockfile.
|
|
23
23
|
const NPM_NA_SECTIONS = new Set(['pnpm-config']);
|
|
24
24
|
|
|
@@ -48,6 +48,7 @@ const RULE_SECTION = {
|
|
|
48
48
|
'no-orphan-packages': 'orphans',
|
|
49
49
|
'unused-dependencies': 'unused',
|
|
50
50
|
'no-fund': 'fund',
|
|
51
|
+
'min-release-age': 'release-age',
|
|
51
52
|
'valid-pnpm-workspace': 'pnpm-config',
|
|
52
53
|
'valid-pnpm-field': 'pnpm-config'
|
|
53
54
|
};
|
|
@@ -77,7 +78,8 @@ const SECTIONS = [
|
|
|
77
78
|
{ id: 'pinned', title: 'Pinned versions' },
|
|
78
79
|
{ id: 'orphans', title: 'Orphaned packages' },
|
|
79
80
|
{ id: 'unused', title: 'Unused dependencies' },
|
|
80
|
-
{ id: 'fund', title: 'Funding solicitations' }
|
|
81
|
+
{ id: 'fund', title: 'Funding solicitations' },
|
|
82
|
+
{ id: 'release-age', title: 'Release-age cooldown' }
|
|
81
83
|
];
|
|
82
84
|
|
|
83
85
|
const MAX_DETAIL = 50; // cap per-section detail lines so the report stays readable
|
|
@@ -247,7 +249,8 @@ function scanSummary(r, flaggedKey, flaggedAdjective, detail = null) {
|
|
|
247
249
|
const n = r[flaggedKey];
|
|
248
250
|
if (n) {
|
|
249
251
|
const unit = `${flaggedAdjective} package${n === 1 ? '' : 's'}`;
|
|
250
|
-
|
|
252
|
+
const suffix = detail ? ` (${detail})` : '';
|
|
253
|
+
bits.push(`${n} ${unit}${suffix}`);
|
|
251
254
|
}
|
|
252
255
|
if (r.skipped) bits.push(`${r.skipped} skipped`);
|
|
253
256
|
return bits.join(' · ');
|
|
@@ -320,7 +323,8 @@ const SECTION_DESCRIBERS = {
|
|
|
320
323
|
// "Unresolved" section — see collectVulnFindings), so its length IS the
|
|
321
324
|
// advisory count, matching the section header 1:1 (SECTION_HEADER_LABEL.vuln).
|
|
322
325
|
const n = findings.length;
|
|
323
|
-
const
|
|
326
|
+
const advisoryWord = n === 1 ? 'advisory' : 'advisories';
|
|
327
|
+
const detail = n ? `${n} ${advisoryWord}` : null;
|
|
324
328
|
return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable', detail));
|
|
325
329
|
},
|
|
326
330
|
deprecated(findings, state) {
|
|
@@ -653,7 +657,8 @@ const DEFAULT_PASS_SUMMARY = {
|
|
|
653
657
|
pinned: 'all pinned',
|
|
654
658
|
orphans: 'none',
|
|
655
659
|
unused: 'none',
|
|
656
|
-
fund: 'suppressed'
|
|
660
|
+
fund: 'suppressed',
|
|
661
|
+
'release-age': 'configured'
|
|
657
662
|
};
|
|
658
663
|
|
|
659
664
|
// moonlitlabs/npm-check#35: one glyph per fixed status (see statusLabel()
|
|
@@ -684,7 +689,8 @@ const SECTION_CATEGORY = {
|
|
|
684
689
|
pinned: 'policy',
|
|
685
690
|
orphans: 'lint',
|
|
686
691
|
unused: 'unused',
|
|
687
|
-
fund: 'lint'
|
|
692
|
+
fund: 'lint',
|
|
693
|
+
'release-age': 'policy'
|
|
688
694
|
};
|
|
689
695
|
|
|
690
696
|
// The report tier is error|warn; the shared ladder needs one of five strings.
|
package/src/schema.js
CHANGED
|
@@ -6,10 +6,30 @@
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
|
+
import { FACTS_SCHEMA_VERSION } from './facts/version.js';
|
|
10
|
+
|
|
11
|
+
export { FACTS_SCHEMA_VERSION };
|
|
9
12
|
|
|
10
13
|
export const TOOL_NAME = 'npm-check';
|
|
11
14
|
export const SCHEMA_VERSION = '1.0';
|
|
12
15
|
|
|
16
|
+
// The `documentType` discriminator for the import-facts document (`npm-check
|
|
17
|
+
// imports`). A findings document has NO `documentType` — schema 1.0 predates
|
|
18
|
+
// the split and the findings envelope is unchanged — so a consumer reads
|
|
19
|
+
// "absent" as findings and "imports" as facts, and never has to guess the
|
|
20
|
+
// payload from whichever key happens to be present. Precedent: pycheck's
|
|
21
|
+
// `--imports` document.
|
|
22
|
+
export const DOCUMENT_TYPE_IMPORTS = 'imports';
|
|
23
|
+
|
|
24
|
+
// `FACTS_SCHEMA_VERSION` (re-exported above) is the facts document's OWN
|
|
25
|
+
// version line — see src/facts/version.js, a dependency-free leaf, so that
|
|
26
|
+
// this module (loaded by every lockfile command) never pulls the facts
|
|
27
|
+
// barrel in and the facts barrel never pulls this one into its type-check.
|
|
28
|
+
|
|
29
|
+
// The envelope-owned keys of a facts document; a body section may never
|
|
30
|
+
// spell one of these (see buildFactsEnvelope).
|
|
31
|
+
const FACTS_IDENTITY_KEYS = new Set(['tool', 'toolVersion', 'schemaVersion', 'documentType', 'target', 'summary']);
|
|
32
|
+
|
|
13
33
|
// The ONE severity ladder, most-severe first.
|
|
14
34
|
export const SEVERITY_LADDER = ['critical', 'high', 'moderate', 'low', 'info'];
|
|
15
35
|
|
|
@@ -74,3 +94,37 @@ export function buildEnvelope({ target, scanned, findings, exitCode, extra }) {
|
|
|
74
94
|
if (extra !== undefined) envelope.extra = extra;
|
|
75
95
|
return envelope;
|
|
76
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Assemble the import-facts envelope: the SAME identity fields as the
|
|
100
|
+
* findings envelope (`tool`, `toolVersion`, `schemaVersion` — the facts
|
|
101
|
+
* document's OWN version line, `FACTS_SCHEMA_VERSION` — `target`,
|
|
102
|
+
* `summary`) plus the `documentType` discriminator, and NO `findings` — an
|
|
103
|
+
* import site has no severity, so it must never ride in the findings array
|
|
104
|
+
* where `--fail-on` could gate on it. `body` is spread after the identity
|
|
105
|
+
* fields: the facts sections (`workspace`, `imports`, `moduleGraph`,
|
|
106
|
+
* `lockfile`, `unanalyzable`); a `summary` inside `body` is ignored in favour
|
|
107
|
+
* of the one passed explicitly, and the identity fields always win.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} args
|
|
110
|
+
* @param {string} args.target - path scanned, as given
|
|
111
|
+
* @param {object} args.summary - the facts summary; `summary.exitCode` MUST equal the real process exit code
|
|
112
|
+
* @param {object} args.body - the document sections
|
|
113
|
+
* @returns {object} the envelope
|
|
114
|
+
*/
|
|
115
|
+
export function buildFactsEnvelope({ target, summary, body }) {
|
|
116
|
+
// Strip anything in `body` that spells an identity field, so the sections
|
|
117
|
+
// can never overwrite the envelope's own claims about what it is.
|
|
118
|
+
const sections = Object.fromEntries(
|
|
119
|
+
Object.entries(body && typeof body === 'object' ? body : {}).filter(([key]) => !FACTS_IDENTITY_KEYS.has(key))
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
tool: TOOL_NAME,
|
|
123
|
+
toolVersion: toolVersion(),
|
|
124
|
+
schemaVersion: FACTS_SCHEMA_VERSION,
|
|
125
|
+
documentType: DOCUMENT_TYPE_IMPORTS,
|
|
126
|
+
target,
|
|
127
|
+
summary,
|
|
128
|
+
...sections
|
|
129
|
+
};
|
|
130
|
+
}
|