@dependably/npm-check 1.9.0 → 1.10.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.
@@ -0,0 +1,183 @@
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), the
5
+ // tsconfig/jsconfig `paths` alias bases (specifiers under one are never a
6
+ // package import), and the first-party source files themselves — everything
7
+ // the scan and the module-graph walk take as their starting point.
8
+ //
9
+ // Ported from sbom-reach's `packages/analyzer-npm/src/workspace.ts`.
10
+ import { readFileSync } from 'node:fs';
11
+ import { dirname, join, relative, sep } from 'node:path';
12
+ import fg from 'fast-glob';
13
+ import ignoreFactory from 'ignore';
14
+ import { aliasBaseFromPathsKey } from './specifier.js';
15
+ import { loadTypeScript } from './ts.js';
16
+
17
+ /** @typedef {import('./types.d.ts').DepScope} DepScope */
18
+ /** @typedef {import('./types.d.ts').Workspace} Workspace */
19
+
20
+ const IGNORE_DIRS = [
21
+ '**/node_modules/**',
22
+ '**/.git/**',
23
+ '**/dist/**',
24
+ '**/build/**',
25
+ '**/out/**',
26
+ '**/coverage/**',
27
+ '**/.next/**',
28
+ '**/.turbo/**',
29
+ '**/vendor/**'
30
+ ];
31
+
32
+ const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,svelte}';
33
+
34
+ /**
35
+ * @param {string} srcDir absolute path of the tree to describe
36
+ * @returns {Workspace}
37
+ */
38
+ export function discoverWorkspace(srcDir) {
39
+ const ts = loadTypeScript();
40
+ /** @type {string[]} */
41
+ const diagnostics = [];
42
+
43
+ const gitignore = loadGitignore(srcDir);
44
+ /** @param {string[]} paths @returns {string[]} */
45
+ const filterIgnored = (paths) => {
46
+ if (!gitignore) return paths;
47
+ return paths.filter((p) => {
48
+ const rel = relative(srcDir, p).split(sep).join('/');
49
+ return rel === '' || !gitignore.ignores(rel);
50
+ });
51
+ };
52
+
53
+ const manifestPaths = filterIgnored(
54
+ fg.sync('**/package.json', {
55
+ cwd: srcDir,
56
+ absolute: true,
57
+ ignore: IGNORE_DIRS,
58
+ dot: false,
59
+ followSymbolicLinks: false
60
+ })
61
+ ).sort();
62
+
63
+ /** @type {Set<string>} */
64
+ const firstPartyNames = new Set();
65
+ /** @type {Map<string, DepScope>} */
66
+ const depScopes = new Map();
67
+
68
+ for (const path of manifestPaths) {
69
+ /** @type {Record<string, unknown>} */
70
+ let json;
71
+ try {
72
+ json = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(path, 'utf8')));
73
+ } catch {
74
+ diagnostics.push(`unparseable package.json at ${relative(srcDir, path)}; skipped`);
75
+ continue;
76
+ }
77
+ if (typeof json.name === 'string') firstPartyNames.add(json.name.toLowerCase());
78
+
79
+ const runtimeSections = ['dependencies', 'optionalDependencies', 'peerDependencies'];
80
+ for (const section of runtimeSections) {
81
+ for (const name of depNames(json[section])) depScopes.set(name, 'runtime');
82
+ }
83
+ for (const name of depNames(json.devDependencies)) {
84
+ if (depScopes.get(name) !== 'runtime') depScopes.set(name, 'dev');
85
+ }
86
+ }
87
+
88
+ /** @type {Set<string>} */
89
+ const aliasPrefixes = new Set();
90
+ const aliasConfigPaths = filterIgnored(
91
+ fg.sync(['**/tsconfig*.json', '**/jsconfig*.json'], {
92
+ cwd: srcDir,
93
+ absolute: true,
94
+ ignore: IGNORE_DIRS,
95
+ dot: false,
96
+ followSymbolicLinks: false
97
+ })
98
+ ).sort();
99
+ for (const path of aliasConfigPaths) {
100
+ const read = ts.readConfigFile(path, (p) => readFileSync(p, 'utf8'));
101
+ if (read.error) {
102
+ diagnostics.push(`unparseable tsconfig/jsconfig at ${relative(srcDir, path)}; aliases from it ignored`);
103
+ continue;
104
+ }
105
+ // Resolve `extends` so inherited paths are honored too.
106
+ /** @type {Record<string, unknown>} */
107
+ let config = /** @type {Record<string, unknown>} */ (read.config);
108
+ const visited = new Set([path]);
109
+ let current = path;
110
+ while (typeof config.extends === 'string') {
111
+ const parentPath = resolveExtends(config.extends, dirname(current));
112
+ if (!parentPath || visited.has(parentPath)) break;
113
+ visited.add(parentPath);
114
+ const parent = ts.readConfigFile(parentPath, (p) => readFileSync(p, 'utf8'));
115
+ if (parent.error) break;
116
+ const parentConfig = /** @type {Record<string, unknown>} */ (parent.config);
117
+ config = {
118
+ ...parentConfig,
119
+ ...config,
120
+ compilerOptions: {
121
+ .../** @type {object | undefined} */ (parentConfig.compilerOptions),
122
+ .../** @type {object | undefined} */ (config.compilerOptions)
123
+ },
124
+ extends: parentConfig.extends
125
+ };
126
+ current = parentPath;
127
+ }
128
+ const compilerOptions = /** @type {{ paths?: Record<string, unknown> } | undefined} */ (config.compilerOptions);
129
+ const paths = compilerOptions?.paths;
130
+ if (paths) {
131
+ for (const key of Object.keys(paths)) aliasPrefixes.add(aliasBaseFromPathsKey(key));
132
+ }
133
+ }
134
+
135
+ const sourceFiles = filterIgnored(
136
+ fg.sync(SOURCE_GLOB, {
137
+ cwd: srcDir,
138
+ absolute: true,
139
+ ignore: IGNORE_DIRS,
140
+ dot: false,
141
+ followSymbolicLinks: false
142
+ })
143
+ ).sort();
144
+
145
+ return { firstPartyNames, depScopes, aliasPrefixes, sourceFiles, diagnostics };
146
+ }
147
+
148
+ /**
149
+ * @param {unknown} section
150
+ * @returns {string[]}
151
+ */
152
+ function depNames(section) {
153
+ if (section === null || typeof section !== 'object') return [];
154
+ return Object.keys(/** @type {Record<string, unknown>} */ (section)).map((n) => n.toLowerCase());
155
+ }
156
+
157
+ /**
158
+ * @param {string} srcDir
159
+ * @returns {ReturnType<typeof ignoreFactory> | undefined}
160
+ */
161
+ function loadGitignore(srcDir) {
162
+ try {
163
+ const content = readFileSync(join(srcDir, '.gitignore'), 'utf8');
164
+ return ignoreFactory().add(content);
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {string} spec
172
+ * @param {string} fromDir
173
+ * @returns {string | undefined}
174
+ */
175
+ function resolveExtends(spec, fromDir) {
176
+ if (spec.startsWith('.') || spec.startsWith('/')) {
177
+ const p = join(fromDir, spec);
178
+ return p.endsWith('.json') ? p : `${p}.json`;
179
+ }
180
+ // Package-based extends (e.g. @tsconfig/node20) would need module resolution;
181
+ // out of scope for alias collection.
182
+ return undefined;
183
+ }
@@ -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
- bits.push(`${n} ${unit}${detail ? ` (${detail})` : ''}`);
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 detail = n ? `${n} advisor${n === 1 ? 'y' : 'ies'}` : null;
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
+ }