@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,221 @@
|
|
|
1
|
+
// src/facts/sourcescan.js
|
|
2
|
+
// What an in-process analyzer is allowed to exclude from its FIRST-PARTY
|
|
3
|
+
// source scan, and how it says so.
|
|
4
|
+
//
|
|
5
|
+
// The rule this file exists to enforce: **a directory's NAME is not evidence
|
|
6
|
+
// that the code inside it is generated.** Excluding `build/` or `dist/` by
|
|
7
|
+
// name was a guess, and where the guess was wrong an analyzer reported
|
|
8
|
+
// `not-observed` -- "I looked and did not find" -- about files it never
|
|
9
|
+
// opened. That is invariant 1's expensive direction (sbom-reach's CLAUDE.md),
|
|
10
|
+
// and it was found in a real repo: SvelteDocs keeps its build pipeline in a
|
|
11
|
+
// tracked, non-gitignored `build/`, the only importer of a high-severity
|
|
12
|
+
// advisory (sbom-reach GitLab #31; sbom-reach commit 95f2b94).
|
|
13
|
+
//
|
|
14
|
+
// `.gitignore` is the authority instead: a project that builds into `dist/`
|
|
15
|
+
// gitignores `dist/`, and one whose source lives in `build/` does not. That
|
|
16
|
+
// moves the decision from a guess to the project's own statement about its
|
|
17
|
+
// own tree.
|
|
18
|
+
//
|
|
19
|
+
// What a scan may STILL exclude by name is a location defined by a tool
|
|
20
|
+
// rather than by convention, holding a COPY of the dependency closure --
|
|
21
|
+
// `node_modules`. That is excluded for a different and correct reason: a
|
|
22
|
+
// dependency's imports are the DEPENDENCY's, and counting them would make
|
|
23
|
+
// every transitive dependency look first-party-imported.
|
|
24
|
+
//
|
|
25
|
+
// Ported verbatim (semantics preserved) from sbom-reach's
|
|
26
|
+
// `packages/core/src/reach/sourcescan.ts`.
|
|
27
|
+
//
|
|
28
|
+
// That TypeScript file is this one's independently-maintained TWIN: npm's
|
|
29
|
+
// facts moved out to this package, but `analyzer-pypi` still imports the
|
|
30
|
+
// original directly, so the gitignore-bounding rules now live in two places.
|
|
31
|
+
// The two were verified to agree by a differential fuzz at port time, but
|
|
32
|
+
// nothing pins them together afterward -- a future change to gitignore
|
|
33
|
+
// precedence or the `OUTPUT_DIR_SCANNED` wording made in only one of them
|
|
34
|
+
// silently diverges the two ecosystems' scan-bounding rules. Port any change
|
|
35
|
+
// to both, or note here why it does not apply to npm.
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { dirname, relative, sep } from 'node:path';
|
|
38
|
+
import fg from 'fast-glob';
|
|
39
|
+
import ignoreFactory from 'ignore';
|
|
40
|
+
|
|
41
|
+
/** @typedef {import('./types.d.ts').GitignoreLayer} GitignoreLayer */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every `.gitignore` under `srcDir`, deepest first -- not just the root one.
|
|
45
|
+
*
|
|
46
|
+
* Reading the root alone is not "letting gitignore decide": a monorepo
|
|
47
|
+
* routinely ignores `dist/` from `packages/foo/.gitignore` and says nothing
|
|
48
|
+
* about it at the root, so a root-only reader would start scanning exactly
|
|
49
|
+
* the built output that dropping hardcoded directory names is not meant to
|
|
50
|
+
* touch.
|
|
51
|
+
*
|
|
52
|
+
* `ignoreDirs` keeps the walk out of `node_modules`, which is both a large
|
|
53
|
+
* speedup and correct: a dependency's own `.gitignore` governs its own
|
|
54
|
+
* repository, not this tree.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} srcDir
|
|
57
|
+
* @param {readonly string[]} ignoreDirs
|
|
58
|
+
* @returns {GitignoreLayer[]}
|
|
59
|
+
*/
|
|
60
|
+
export function loadGitignores(srcDir, ignoreDirs) {
|
|
61
|
+
const paths = fg.sync('**/.gitignore', {
|
|
62
|
+
cwd: srcDir,
|
|
63
|
+
absolute: true,
|
|
64
|
+
ignore: [...ignoreDirs],
|
|
65
|
+
dot: false,
|
|
66
|
+
followSymbolicLinks: false,
|
|
67
|
+
suppressErrors: true
|
|
68
|
+
});
|
|
69
|
+
/** @type {GitignoreLayer[]} */
|
|
70
|
+
const layers = [];
|
|
71
|
+
for (const path of paths) {
|
|
72
|
+
/** @type {string} */
|
|
73
|
+
let content;
|
|
74
|
+
try {
|
|
75
|
+
content = readFileSync(path, 'utf8');
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
layers.push({
|
|
80
|
+
dir: relative(srcDir, dirname(path)).split(sep).join('/'),
|
|
81
|
+
matcher: ignoreFactory().add(content)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Deepest first: git gives the nearest `.gitignore` the last word, including
|
|
85
|
+
// its right to re-include with `!` something a parent ignored.
|
|
86
|
+
layers.sort((a, b) => b.dir.length - a.dir.length || a.dir.localeCompare(b.dir));
|
|
87
|
+
return layers;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Is `rel` (relative to srcDir, `/`-joined) ignored, by git's own rules?
|
|
92
|
+
*
|
|
93
|
+
* Component by component, outermost first, because that is how git decides:
|
|
94
|
+
* each path component is judged by the NEAREST `.gitignore` that says
|
|
95
|
+
* anything about it, and an ignored DIRECTORY ends the walk -- git does not
|
|
96
|
+
* descend into one, so no deeper rule and no deeper `.gitignore` inside it
|
|
97
|
+
* can re-include anything. Doing it this way is what makes `!build/` in
|
|
98
|
+
* `tools/.gitignore` re-include `tools/build/` against a root that ignores
|
|
99
|
+
* `build/`, which real git does and a single flat matcher does not.
|
|
100
|
+
*
|
|
101
|
+
* @param {readonly GitignoreLayer[]} layers
|
|
102
|
+
* @param {string} rel
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isGitignored(layers, rel) {
|
|
106
|
+
if (layers.length === 0 || rel === '' || rel.startsWith('..')) return false;
|
|
107
|
+
const parts = rel.split('/');
|
|
108
|
+
for (let i = 0; i < parts.length; i++) {
|
|
109
|
+
const path = parts.slice(0, i + 1).join('/');
|
|
110
|
+
// A trailing slash is how `ignore` is told the path is a directory, which
|
|
111
|
+
// decides whether a directory-only pattern (`build/`) matches it at all.
|
|
112
|
+
const isDir = i < parts.length - 1;
|
|
113
|
+
if (componentIgnored(layers, path, isDir ? `${path}/` : path)) return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The nearest layer with an explicit verdict about one path component decides it.
|
|
120
|
+
* @param {readonly GitignoreLayer[]} layers
|
|
121
|
+
* @param {string} path
|
|
122
|
+
* @param {string} testPath
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
function componentIgnored(layers, path, testPath) {
|
|
126
|
+
const slash = path.lastIndexOf('/');
|
|
127
|
+
const parent = slash === -1 ? '' : path.slice(0, slash);
|
|
128
|
+
for (const layer of layers) {
|
|
129
|
+
// Layers are deepest-first; one applies when its directory is the
|
|
130
|
+
// component's own directory or an ancestor of it.
|
|
131
|
+
if (layer.dir !== '' && parent !== layer.dir && !parent.startsWith(`${layer.dir}/`)) continue;
|
|
132
|
+
const rel = layer.dir === '' ? path : path.slice(layer.dir.length + 1);
|
|
133
|
+
const sub = layer.dir === '' ? testPath : testPath.slice(layer.dir.length + 1);
|
|
134
|
+
// `ignore` reports a path as ignored when an ANCESTOR directory of it
|
|
135
|
+
// matched, which is its own walk, not this one. Disregard that: the
|
|
136
|
+
// ancestor was judged on its own turn above, and reaching this component
|
|
137
|
+
// at all means a nearer layer re-included it.
|
|
138
|
+
if (rel.includes('/') && layer.matcher.test(`${rel.slice(0, rel.lastIndexOf('/'))}/`).ignored) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const result = layer.matcher.test(sub);
|
|
142
|
+
if (result.ignored) return true;
|
|
143
|
+
if (result.unignored) return false;
|
|
144
|
+
// Silent about this component: keep walking outwards.
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Drop the absolute paths under `srcDir` that a `.gitignore` in the tree ignores.
|
|
151
|
+
* @param {string} srcDir
|
|
152
|
+
* @param {readonly GitignoreLayer[]} layers
|
|
153
|
+
* @param {string[]} paths
|
|
154
|
+
* @returns {string[]}
|
|
155
|
+
*/
|
|
156
|
+
export function filterGitignored(srcDir, layers, paths) {
|
|
157
|
+
if (layers.length === 0) return paths;
|
|
158
|
+
return paths.filter((p) => !isGitignored(layers, relative(srcDir, p).split(sep).join('/')));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Directory names that usually DO hold generated or vendored output. Nothing
|
|
163
|
+
* is excluded for being on this list -- it exists only so that scanning one
|
|
164
|
+
* can be said out loud, for the project that commits its build output
|
|
165
|
+
* without gitignoring it and then wonders why its evidence points into a
|
|
166
|
+
* bundle.
|
|
167
|
+
* @type {readonly string[]}
|
|
168
|
+
*/
|
|
169
|
+
export const OUTPUT_SHAPED_DIRS = ['build', 'coverage', 'dist', 'out', 'vendor'];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `OUTPUT_DIR_SCANNED`, or undefined when no such directory was scanned.
|
|
173
|
+
*
|
|
174
|
+
* Deliberately a NOTE, not a warning. A warning marks a run that examined
|
|
175
|
+
* LESS than it appears to -- a degraded run that must not look clean. This
|
|
176
|
+
* read MORE of the tree.
|
|
177
|
+
*
|
|
178
|
+
* Reading more is not free, and the honest version of this rationale says
|
|
179
|
+
* so: a newly-scanned directory can SHADOW a package name -- a top-level
|
|
180
|
+
* `build/` against the `build` distribution, a committed `vendor/js-yaml`
|
|
181
|
+
* against `js-yaml` -- and a consumer answers `unknown` for that, which
|
|
182
|
+
* gates LESS than the `reachable` it replaced. What holds is that no
|
|
183
|
+
* negative gets SILENTLY weaker: each of those is reported on the finding
|
|
184
|
+
* itself, naming the colliding name and the path that owns it, instead of
|
|
185
|
+
* becoming a confident negative (invariant 1). A warning marks a gap the
|
|
186
|
+
* findings do not carry; this one they do.
|
|
187
|
+
*
|
|
188
|
+
* It still has to be visible, because a user who sees evidence at
|
|
189
|
+
* `dist/bundle.js` should be able to find out why a bundle counted as
|
|
190
|
+
* first-party source. Raising it to a warning would also fire on every run
|
|
191
|
+
* of every repo that commits a built `dist/`, which is how a signal becomes
|
|
192
|
+
* noise everyone skims past.
|
|
193
|
+
*
|
|
194
|
+
* @param {readonly string[]} relPaths scanned source files, relative to srcDir, `/`-joined.
|
|
195
|
+
* @param {readonly string[]} [names]
|
|
196
|
+
* @returns {string | undefined}
|
|
197
|
+
*/
|
|
198
|
+
export function outputDirScannedDiagnostic(relPaths, names = OUTPUT_SHAPED_DIRS) {
|
|
199
|
+
const nameSet = new Set(names);
|
|
200
|
+
/** @type {Set<string>} */
|
|
201
|
+
const scanned = new Set();
|
|
202
|
+
for (const rel of relPaths) {
|
|
203
|
+
const segments = rel.split('/');
|
|
204
|
+
for (const segment of segments.slice(0, -1)) {
|
|
205
|
+
if (nameSet.has(segment)) scanned.add(segment);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (scanned.size === 0) return undefined;
|
|
209
|
+
const listed = [...scanned]
|
|
210
|
+
.sort()
|
|
211
|
+
.map((n) => `${n}/`)
|
|
212
|
+
.join(', ');
|
|
213
|
+
return (
|
|
214
|
+
`OUTPUT_DIR_SCANNED: scanned source under ${listed}, which no .gitignore under srcDir ` +
|
|
215
|
+
'ignores. A directory name is not evidence, so those files are read as first-party source — ' +
|
|
216
|
+
'the alternative is claiming `not-observed` for a package only such a file imports. One of ' +
|
|
217
|
+
'them may also SHADOW a package of the same name, in which case that finding is reported ' +
|
|
218
|
+
'`unknown` naming the collision rather than as a negative. If they really are generated ' +
|
|
219
|
+
'output, gitignore them and they will be excluded.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/facts/specifier.js
|
|
2
|
+
// Module specifier → npm package name. This coexists with usage-scanner.js's
|
|
3
|
+
// `specifierToPackageName` on purpose: that one answers "which declared
|
|
4
|
+
// dependency does this mention count towards" for the unused-dependency
|
|
5
|
+
// heuristic (and so treats `@types/foo` specially); this one is the language
|
|
6
|
+
// fact — the package a specifier names, or nothing when it names no package.
|
|
7
|
+
import { builtinModules } from 'node:module';
|
|
8
|
+
|
|
9
|
+
const BUILTINS = new Set(builtinModules);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map a module specifier to the npm package it belongs to (lower-cased, since
|
|
13
|
+
* npm names are case-insensitively unique), or undefined when the specifier is
|
|
14
|
+
* not a package import: a relative/absolute path, a builtin, a `#imports`
|
|
15
|
+
* key, a data:/file: URL, or a tsconfig/jsconfig path alias.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} spec
|
|
18
|
+
* @param {ReadonlySet<string>} aliasPrefixes
|
|
19
|
+
* @returns {string | undefined}
|
|
20
|
+
*/
|
|
21
|
+
export function specifierToPackage(spec, aliasPrefixes) {
|
|
22
|
+
if (spec.length === 0) return undefined;
|
|
23
|
+
if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#')) return undefined;
|
|
24
|
+
if (spec.startsWith('node:') || spec.startsWith('data:') || spec.startsWith('file:')) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (BUILTINS.has(spec.split('/')[0])) return undefined;
|
|
28
|
+
|
|
29
|
+
for (const alias of aliasPrefixes) {
|
|
30
|
+
if (spec === alias || spec.startsWith(`${alias}/`)) return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parts = spec.split('/');
|
|
34
|
+
if (spec.startsWith('@')) {
|
|
35
|
+
if (parts.length < 2) return undefined;
|
|
36
|
+
return `${parts[0]}/${parts[1]}`.toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
return parts[0].toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* tsconfig/jsconfig `paths` keys ("@app/*", "utils") → alias bases ("@app", "utils").
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function aliasBaseFromPathsKey(key) {
|
|
47
|
+
return key.endsWith('/*') ? key.slice(0, -2) : key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @typedef {import('./types.d.ts').AliasScope} AliasScope */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which path aliases are in scope for a given file.
|
|
54
|
+
*
|
|
55
|
+
* A `paths` map belongs to the tsconfig/jsconfig that declares it and governs
|
|
56
|
+
* that project's own files -- which is what `tsc` does, and what this
|
|
57
|
+
* models. One flat workspace-wide set was the earlier shape, and it let ANY
|
|
58
|
+
* config anywhere under the scanned tree delete a package's evidence in
|
|
59
|
+
* EVERY file: with output-shaped directory names no longer excluded by name,
|
|
60
|
+
* a committed `vendor/lib/tsconfig.json` mapping `js-yaml` turned a live
|
|
61
|
+
* import in `src/` into `not-observed` at high confidence with no evidence
|
|
62
|
+
* and no diagnostic (sbom-reach commit 95f2b94, round-3 adversarial review).
|
|
63
|
+
* Narrowing by subtree, rather than by a skip-list of directory names, is the
|
|
64
|
+
* fix that does not reintroduce name-based reasoning one layer up.
|
|
65
|
+
*
|
|
66
|
+
* @type {ReadonlySet<string>}
|
|
67
|
+
*/
|
|
68
|
+
const NO_ALIASES = new Set();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* An `AliasScope` that answers the same set everywhere -- tests, and the
|
|
72
|
+
* empty default.
|
|
73
|
+
* @param {ReadonlySet<string>} [prefixes]
|
|
74
|
+
* @returns {AliasScope}
|
|
75
|
+
*/
|
|
76
|
+
export function fixedAliasScope(prefixes = NO_ALIASES) {
|
|
77
|
+
return { for: () => prefixes };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Accept either shape at an API boundary without making every caller care.
|
|
82
|
+
* @param {ReadonlySet<string> | AliasScope} aliases
|
|
83
|
+
* @returns {AliasScope}
|
|
84
|
+
*/
|
|
85
|
+
export function asAliasScope(aliases) {
|
|
86
|
+
return typeof (/** @type {AliasScope} */ (aliases).for) === 'function'
|
|
87
|
+
? /** @type {AliasScope} */ (aliases)
|
|
88
|
+
: fixedAliasScope(/** @type {ReadonlySet<string>} */ (aliases));
|
|
89
|
+
}
|
package/src/facts/ts.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/facts/ts.js
|
|
2
|
+
// `typescript` is an OPTIONAL peer dependency: the lockfile commands never need
|
|
3
|
+
// it, and a consumer of the package root must not pay for a 10 MB compiler it
|
|
4
|
+
// never loads. It is therefore resolved lazily and synchronously (createRequire,
|
|
5
|
+
// the same pattern `parser.js` uses for `yaml`), the first time a facts module
|
|
6
|
+
// actually parses something, and its absence is a coded error — the CLI maps
|
|
7
|
+
// `TYPESCRIPT_MISSING` to a usage error (exit 2) with an install hint rather
|
|
8
|
+
// than a stack trace.
|
|
9
|
+
//
|
|
10
|
+
// NOTHING here runs at module load. `createRequire` is built inside
|
|
11
|
+
// `loadTypeScript()`, from `__filename` when a CJS bundle defines it and from
|
|
12
|
+
// `import.meta.url` otherwise: esbuild rewrites `import.meta.url` to
|
|
13
|
+
// `undefined` in a CJS bundle, and `createRequire(undefined)` throws
|
|
14
|
+
// `ERR_INVALID_ARG_VALUE` — at import time, as a plain Error no
|
|
15
|
+
// `TYPESCRIPT_MISSING` handler would ever see (adversarial review). Importing
|
|
16
|
+
// the barrel must never throw; only a parse can.
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { FactsError } from './errors.js';
|
|
19
|
+
|
|
20
|
+
/** @type {typeof import('typescript') | undefined} */
|
|
21
|
+
let cached;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The TypeScript compiler API, loaded once.
|
|
25
|
+
* @returns {typeof import('typescript')}
|
|
26
|
+
* @throws {FactsError} `TYPESCRIPT_MISSING` when the peer is not installed.
|
|
27
|
+
*/
|
|
28
|
+
export function loadTypeScript() {
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
// `__filename` is only defined in a CommonJS context (a bundle); in ESM the
|
|
31
|
+
// `typeof` guard keeps the reference from throwing.
|
|
32
|
+
const anchor = typeof __filename !== 'undefined' ? __filename : import.meta.url;
|
|
33
|
+
const require = createRequire(anchor);
|
|
34
|
+
try {
|
|
35
|
+
cached = /** @type {typeof import('typescript')} */ (require('typescript'));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
38
|
+
if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err;
|
|
39
|
+
throw new FactsError(
|
|
40
|
+
'TYPESCRIPT_MISSING',
|
|
41
|
+
'import facts need the `typescript` package (an optional peer dependency of @dependably/npm-check), ' +
|
|
42
|
+
'which is not installed. Install it alongside npm-check: `npm install --save-dev typescript` ' +
|
|
43
|
+
'(any 5.6+ release).'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return cached;
|
|
47
|
+
}
|