@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.
@@ -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
+ }
@@ -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
+ }