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