@dependably/npm-check 1.10.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/package.json +1 -1
- package/src/facts/collect.js +2 -2
- package/src/facts/document.js +11 -0
- package/src/facts/index.js +3 -2
- package/src/facts/resolve.js +13 -6
- package/src/facts/sourcescan.js +221 -0
- package/src/facts/specifier.js +41 -0
- package/src/facts/types.d.ts +90 -4
- package/src/facts/version.js +8 -3
- package/src/facts/workspace.js +136 -41
package/package.json
CHANGED
package/src/facts/collect.js
CHANGED
|
@@ -95,7 +95,7 @@ export function collectImportFacts(srcDir, options = {}) {
|
|
|
95
95
|
const relOf = makeRelOf(absSrcDir, realSrcDir);
|
|
96
96
|
|
|
97
97
|
const workspace = discoverWorkspace(absSrcDir);
|
|
98
|
-
const resolver = new ModuleResolver(workspace.
|
|
98
|
+
const resolver = new ModuleResolver(workspace.aliasScope);
|
|
99
99
|
|
|
100
100
|
/** @type {UnanalyzableEntry[]} */
|
|
101
101
|
const unanalyzable = [];
|
|
@@ -127,7 +127,7 @@ export function collectImportFacts(srcDir, options = {}) {
|
|
|
127
127
|
}
|
|
128
128
|
/** @type {ResolvedSite[]} */
|
|
129
129
|
const sites = result.sites.map((site) => {
|
|
130
|
-
const pkg = specifierToPackage(site.specifier, workspace.
|
|
130
|
+
const pkg = specifierToPackage(site.specifier, workspace.aliasScope.for(file));
|
|
131
131
|
// Which installed copy does this statement load? Version-accurate
|
|
132
132
|
// attribution is the consumer's, but the fact — the copy the resolver
|
|
133
133
|
// lands in — is established here, once, with the same resolver the
|
package/src/facts/document.js
CHANGED
|
@@ -109,6 +109,17 @@ export function factsDocument(facts, options = {}) {
|
|
|
109
109
|
.map(([name, scope]) => ({ name, scope }))
|
|
110
110
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
111
111
|
aliasPrefixes: [...ws.aliasPrefixes].sort(),
|
|
112
|
+
// `aliasScope` itself is a closure (`.for(file)`) and cannot be
|
|
113
|
+
// serialized; this projects the same per-config information it answers
|
|
114
|
+
// from, the way `reached`/`unresolvedByName` project the module graph's
|
|
115
|
+
// Maps above -- one entry per tsconfig/jsconfig that declared `paths`,
|
|
116
|
+
// `dir` made target-relative and POSIX like every other path here.
|
|
117
|
+
aliasScope: ws.aliasLayers
|
|
118
|
+
.map((layer) => ({ dir: relOf(layer.dir), prefixes: [...layer.prefixes].sort() }))
|
|
119
|
+
.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)),
|
|
120
|
+
devDeclaredBy: [...ws.devDeclaredBy]
|
|
121
|
+
.map(([name, manifests]) => ({ name, manifests: [...manifests].sort() }))
|
|
122
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
|
112
123
|
sourceFiles: ws.sourceFiles.length,
|
|
113
124
|
diagnostics: ws.diagnostics
|
|
114
125
|
};
|
package/src/facts/index.js
CHANGED
|
@@ -32,5 +32,6 @@ export {
|
|
|
32
32
|
parsePnpmLockYaml,
|
|
33
33
|
parsePnpmLockYamlGraph
|
|
34
34
|
} from './lockfile-graph.js';
|
|
35
|
-
export { specifierToPackage, aliasBaseFromPathsKey } from './specifier.js';
|
|
36
|
-
export { discoverWorkspace } from './workspace.js';
|
|
35
|
+
export { specifierToPackage, aliasBaseFromPathsKey, fixedAliasScope, asAliasScope } from './specifier.js';
|
|
36
|
+
export { discoverWorkspace, governedByManifest } from './workspace.js';
|
|
37
|
+
export { loadGitignores, isGitignored, filterGitignored, OUTPUT_SHAPED_DIRS, outputDirScannedDiagnostic } from './sourcescan.js';
|
package/src/facts/resolve.js
CHANGED
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
32
32
|
import { builtinModules } from 'node:module';
|
|
33
33
|
import { basename, dirname, isAbsolute, join, resolve as pathResolve, sep } from 'node:path';
|
|
34
|
+
import { asAliasScope } from './specifier.js';
|
|
35
|
+
|
|
36
|
+
/** @typedef {import('./types.d.ts').AliasScope} AliasScope */
|
|
34
37
|
|
|
35
38
|
/** @typedef {import('./types.d.ts').PackageInfo} PackageInfo */
|
|
36
39
|
/** @typedef {import('./types.d.ts').Resolution} Resolution */
|
|
@@ -61,12 +64,16 @@ const NODE_MODULES = 'node_modules';
|
|
|
61
64
|
|
|
62
65
|
export class ModuleResolver {
|
|
63
66
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
67
|
+
* `aliases` is per-FILE (`AliasScope`), because a tsconfig's `paths`
|
|
68
|
+
* governs its own project rather than the whole tree. A plain set is still
|
|
69
|
+
* accepted and means "these everywhere", which is what most callers and
|
|
70
|
+
* tests want.
|
|
71
|
+
* @param {ReadonlySet<string> | AliasScope} [aliases] tsconfig/jsconfig
|
|
72
|
+
* path-alias bases; a specifier under one resolves to `{ kind: 'alias' }`.
|
|
66
73
|
*/
|
|
67
|
-
constructor(
|
|
68
|
-
/** @type {
|
|
69
|
-
this.
|
|
74
|
+
constructor(aliases = new Set()) {
|
|
75
|
+
/** @type {AliasScope} */
|
|
76
|
+
this.aliasScope = asAliasScope(aliases);
|
|
70
77
|
/** @type {Map<string, 'file' | 'dir' | null>} */
|
|
71
78
|
this.statCache = new Map();
|
|
72
79
|
/** @type {Map<string, PackageJson | null>} */
|
|
@@ -104,7 +111,7 @@ export class ModuleResolver {
|
|
|
104
111
|
}
|
|
105
112
|
if (cleaned.startsWith('#')) return this.resolveImportsField(fromFile, cleaned, mode);
|
|
106
113
|
|
|
107
|
-
for (const alias of this.
|
|
114
|
+
for (const alias of this.aliasScope.for(fromFile)) {
|
|
108
115
|
if (cleaned === alias || cleaned.startsWith(`${alias}/`)) return { kind: 'alias' };
|
|
109
116
|
}
|
|
110
117
|
return this.resolveBare(fromFile, cleaned, mode);
|
|
@@ -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
|
+
}
|
package/src/facts/specifier.js
CHANGED
|
@@ -46,3 +46,44 @@ export function specifierToPackage(spec, aliasPrefixes) {
|
|
|
46
46
|
export function aliasBaseFromPathsKey(key) {
|
|
47
47
|
return key.endsWith('/*') ? key.slice(0, -2) : key;
|
|
48
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/types.d.ts
CHANGED
|
@@ -112,8 +112,12 @@ export type ResolveMode = 'import' | 'require';
|
|
|
112
112
|
* packages come back as their own `Resolution` kinds.
|
|
113
113
|
*/
|
|
114
114
|
export class ModuleResolver {
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
/**
|
|
116
|
+
* `aliases` is per-FILE (`AliasScope`), because a tsconfig's `paths`
|
|
117
|
+
* governs its own project rather than the whole tree. A plain set is still
|
|
118
|
+
* accepted and means "these everywhere".
|
|
119
|
+
*/
|
|
120
|
+
constructor(aliases?: ReadonlySet<string> | AliasScope);
|
|
117
121
|
resolve(fromFile: string, specifier: string, mode: ResolveMode): Resolution;
|
|
118
122
|
/**
|
|
119
123
|
* The package a file belongs to, from its path alone: the directory right
|
|
@@ -286,23 +290,95 @@ export function specifierToPackage(spec: string, aliasPrefixes: ReadonlySet<stri
|
|
|
286
290
|
/** tsconfig/jsconfig `paths` keys ("@app/*", "utils") → alias bases ("@app", "utils"). */
|
|
287
291
|
export function aliasBaseFromPathsKey(key: string): string;
|
|
288
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Which path aliases are in scope for a given file.
|
|
295
|
+
*
|
|
296
|
+
* A `paths` map belongs to the tsconfig/jsconfig that declares it and governs
|
|
297
|
+
* that project's own files -- which is what `tsc` does. One flat
|
|
298
|
+
* workspace-wide set would let ANY config anywhere under the scanned tree
|
|
299
|
+
* delete a package's evidence in EVERY file.
|
|
300
|
+
*/
|
|
301
|
+
export interface AliasScope {
|
|
302
|
+
/** Alias bases in scope for `file`, an absolute path. */
|
|
303
|
+
for(file: string): ReadonlySet<string>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** An `AliasScope` that answers the same set everywhere -- tests, and the empty default. */
|
|
307
|
+
export function fixedAliasScope(prefixes?: ReadonlySet<string>): AliasScope;
|
|
308
|
+
/** Accept either shape at an API boundary without making every caller care. */
|
|
309
|
+
export function asAliasScope(aliases: ReadonlySet<string> | AliasScope): AliasScope;
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------- sourcescan ----
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What an in-process analyzer is allowed to exclude from its first-party
|
|
315
|
+
* source scan, and how it says so. See `sourcescan.js` for the full
|
|
316
|
+
* rationale: a directory's NAME is not evidence that the code inside it is
|
|
317
|
+
* generated -- `.gitignore` is the authority instead.
|
|
318
|
+
*/
|
|
319
|
+
export interface GitignoreLayer {
|
|
320
|
+
/** Directory the file sits in, relative to srcDir, `/`-joined; `''` for the root one. */
|
|
321
|
+
dir: string;
|
|
322
|
+
/** An `ignore` package matcher built from that directory's `.gitignore` content. */
|
|
323
|
+
matcher: { test(path: string): { ignored: boolean; unignored: boolean } };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Every `.gitignore` under `srcDir`, deepest first -- not just the root one. */
|
|
327
|
+
export function loadGitignores(srcDir: string, ignoreDirs: readonly string[]): GitignoreLayer[];
|
|
328
|
+
/** Is `rel` (relative to srcDir, `/`-joined) ignored, by git's own rules? */
|
|
329
|
+
export function isGitignored(layers: readonly GitignoreLayer[], rel: string): boolean;
|
|
330
|
+
/** Drop the absolute paths under `srcDir` that a `.gitignore` in the tree ignores. */
|
|
331
|
+
export function filterGitignored(srcDir: string, layers: readonly GitignoreLayer[], paths: string[]): string[];
|
|
332
|
+
/** Directory names that usually DO hold generated or vendored output; nothing is excluded for being on this list. */
|
|
333
|
+
export const OUTPUT_SHAPED_DIRS: readonly string[];
|
|
334
|
+
/** `OUTPUT_DIR_SCANNED`, or undefined when no such directory was scanned. A NOTE, not a warning. */
|
|
335
|
+
export function outputDirScannedDiagnostic(relPaths: readonly string[], names?: readonly string[]): string | undefined;
|
|
336
|
+
|
|
289
337
|
// ----------------------------------------------------------- workspace ----
|
|
290
338
|
|
|
291
339
|
export type DepScope = 'runtime' | 'dev';
|
|
292
340
|
|
|
341
|
+
/** One tsconfig/jsconfig's own `paths` alias bases and the directory it governs (absolute path). */
|
|
342
|
+
export interface AliasLayer {
|
|
343
|
+
dir: string;
|
|
344
|
+
prefixes: string[];
|
|
345
|
+
}
|
|
346
|
+
|
|
293
347
|
export interface Workspace {
|
|
294
348
|
/** Names of package.json manifests found in the tree = first-party packages (lower-cased). */
|
|
295
349
|
firstPartyNames: Set<string>;
|
|
296
350
|
/** name → runtime|dev; "runtime anywhere wins" across all manifests. */
|
|
297
351
|
depScopes: Map<string, DepScope>;
|
|
298
|
-
/**
|
|
352
|
+
/**
|
|
353
|
+
* Every tsconfig/jsconfig paths alias base found anywhere in the tree.
|
|
354
|
+
*
|
|
355
|
+
* FOR REPORTING ONLY -- never decide a specifier with this. A `paths` map
|
|
356
|
+
* governs the project that declares it, so use `aliasScope`, which answers
|
|
357
|
+
* per file.
|
|
358
|
+
*/
|
|
299
359
|
aliasPrefixes: Set<string>;
|
|
360
|
+
/** Which alias bases apply to a given file -- see `AliasScope`. */
|
|
361
|
+
aliasScope: AliasScope;
|
|
362
|
+
/** The raw per-config layers `aliasScope` is built from (absolute directories); carried for JSON serialization. */
|
|
363
|
+
aliasLayers: AliasLayer[];
|
|
364
|
+
/**
|
|
365
|
+
* name -> the manifests that declared it a DEV dependency, `/`-joined and
|
|
366
|
+
* relative to srcDir, for names no manifest declares runtime.
|
|
367
|
+
*
|
|
368
|
+
* A dev claim is one manifest's view, and a manifest's view covers its own
|
|
369
|
+
* subtree. Recording WHERE the claim came from is what lets a consumer
|
|
370
|
+
* refuse a vendored tool's `devDependencies` as the reason a package
|
|
371
|
+
* imported from `src/` is exempt from a build gate.
|
|
372
|
+
*/
|
|
373
|
+
devDeclaredBy: Map<string, string[]>;
|
|
300
374
|
/** First-party source files, absolute paths, sorted. */
|
|
301
375
|
sourceFiles: string[];
|
|
302
376
|
diagnostics: string[];
|
|
303
377
|
}
|
|
304
378
|
|
|
305
379
|
export function discoverWorkspace(srcDir: string): Workspace;
|
|
380
|
+
/** Is `rel` (a `/`-joined path) inside the directory of the manifest at `manifestRel`? */
|
|
381
|
+
export function governedByManifest(manifestRel: string, rel: string): boolean;
|
|
306
382
|
|
|
307
383
|
// ------------------------------------------------------------- collect ----
|
|
308
384
|
|
|
@@ -444,6 +520,16 @@ export interface FactsDocumentBody {
|
|
|
444
520
|
firstPartyNames: string[];
|
|
445
521
|
depScopes: { name: string; scope: DepScope }[];
|
|
446
522
|
aliasPrefixes: string[];
|
|
523
|
+
/**
|
|
524
|
+
* `aliasScope`'s raw per-config layers, JSON-safe: `aliasScope` itself is
|
|
525
|
+
* a closure and cannot be serialized, so this projects the same
|
|
526
|
+
* information the live `AliasScope` answers `.for(file)` from -- one
|
|
527
|
+
* entry per tsconfig/jsconfig that declared `paths`, `dir` relative to
|
|
528
|
+
* the target and `/`-joined.
|
|
529
|
+
*/
|
|
530
|
+
aliasScope: { dir: string; prefixes: string[] }[];
|
|
531
|
+
/** `devDeclaredBy`, JSON-safe: one entry per name with an unresolved dev claim. */
|
|
532
|
+
devDeclaredBy: { name: string; manifests: string[] }[];
|
|
447
533
|
sourceFiles: number;
|
|
448
534
|
diagnostics: string[];
|
|
449
535
|
};
|
|
@@ -482,7 +568,7 @@ export function factsDocument(facts: ImportFacts, options?: { exitCode?: number
|
|
|
482
568
|
|
|
483
569
|
// --------------------------------------------------------------- misc ----
|
|
484
570
|
|
|
485
|
-
export const FACTS_SCHEMA_VERSION: '1.
|
|
571
|
+
export const FACTS_SCHEMA_VERSION: '1.1';
|
|
486
572
|
|
|
487
573
|
export class FactsError extends Error {
|
|
488
574
|
constructor(code: string, message: string);
|
package/src/facts/version.js
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
// The import-facts document's schema version — a SEPARATE version line from
|
|
3
3
|
// `src/schema.js`'s `SCHEMA_VERSION` (the findings envelope's), so a change
|
|
4
4
|
// to the facts shape never implies one to the findings envelope and vice
|
|
5
|
-
// versa.
|
|
6
|
-
// minor, a renamed or removed one its major.
|
|
5
|
+
// versa. `src/schema.js`'s version is still `1.0`; a field added to the
|
|
6
|
+
// facts document bumps its minor, a renamed or removed one its major. Bumped
|
|
7
|
+
// to `1.1` when `workspace.aliasScope` and `workspace.devDeclaredBy` were
|
|
8
|
+
// added — both are additive, so a consumer already reading `1.0` still
|
|
9
|
+
// parses the document, but can now tell "this producer predates these
|
|
10
|
+
// fields" apart from "no manifest made a dev claim", which is the whole
|
|
11
|
+
// point of tracking the minor at all.
|
|
7
12
|
//
|
|
8
13
|
// A dependency-free leaf on purpose: `src/schema.js` (loaded by every
|
|
9
14
|
// lockfile command) imports THIS file to stamp `buildFactsEnvelope`, and the
|
|
10
15
|
// facts barrel re-exports it — neither side pulls the other's imports in.
|
|
11
|
-
export const FACTS_SCHEMA_VERSION = '1.
|
|
16
|
+
export const FACTS_SCHEMA_VERSION = '1.1';
|
package/src/facts/workspace.js
CHANGED
|
@@ -1,33 +1,50 @@
|
|
|
1
1
|
// src/facts/workspace.js
|
|
2
2
|
// What the tree under `srcDir` declares about itself: the first-party package
|
|
3
3
|
// names (every package.json's `name`), the dev/runtime scope each manifest
|
|
4
|
-
// gives its dependencies ("runtime anywhere wins" across manifests
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
8
10
|
//
|
|
9
|
-
// Ported from sbom-reach's `packages/analyzer-npm/src/workspace.ts
|
|
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).
|
|
10
14
|
import { readFileSync } from 'node:fs';
|
|
11
15
|
import { dirname, join, relative, sep } from 'node:path';
|
|
12
16
|
import fg from 'fast-glob';
|
|
13
|
-
import ignoreFactory from 'ignore';
|
|
14
17
|
import { aliasBaseFromPathsKey } from './specifier.js';
|
|
18
|
+
import { filterGitignored, loadGitignores, outputDirScannedDiagnostic } from './sourcescan.js';
|
|
15
19
|
import { loadTypeScript } from './ts.js';
|
|
16
20
|
|
|
17
21
|
/** @typedef {import('./types.d.ts').DepScope} DepScope */
|
|
18
22
|
/** @typedef {import('./types.d.ts').Workspace} Workspace */
|
|
23
|
+
/** @typedef {import('./types.d.ts').AliasScope} AliasScope */
|
|
24
|
+
/** @typedef {import('./types.d.ts').AliasLayer} AliasLayer */
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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/**'];
|
|
31
48
|
|
|
32
49
|
const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,svelte}';
|
|
33
50
|
|
|
@@ -40,15 +57,9 @@ export function discoverWorkspace(srcDir) {
|
|
|
40
57
|
/** @type {string[]} */
|
|
41
58
|
const diagnostics = [];
|
|
42
59
|
|
|
43
|
-
const
|
|
60
|
+
const gitignores = loadGitignores(srcDir, IGNORE_DIRS);
|
|
44
61
|
/** @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
|
-
};
|
|
62
|
+
const filterIgnored = (paths) => filterGitignored(srcDir, gitignores, paths);
|
|
52
63
|
|
|
53
64
|
const manifestPaths = filterIgnored(
|
|
54
65
|
fg.sync('**/package.json', {
|
|
@@ -64,6 +75,9 @@ export function discoverWorkspace(srcDir) {
|
|
|
64
75
|
const firstPartyNames = new Set();
|
|
65
76
|
/** @type {Map<string, DepScope>} */
|
|
66
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();
|
|
67
81
|
|
|
68
82
|
for (const path of manifestPaths) {
|
|
69
83
|
/** @type {Record<string, unknown>} */
|
|
@@ -82,11 +96,20 @@ export function discoverWorkspace(srcDir) {
|
|
|
82
96
|
}
|
|
83
97
|
for (const name of depNames(json.devDependencies)) {
|
|
84
98
|
if (depScopes.get(name) !== 'runtime') depScopes.set(name, 'dev');
|
|
99
|
+
devDeclaredBy.set(name, [...(devDeclaredBy.get(name) ?? []), relative(srcDir, path).split(sep).join('/')]);
|
|
85
100
|
}
|
|
86
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);
|
|
87
107
|
|
|
88
108
|
/** @type {Set<string>} */
|
|
89
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 = [];
|
|
90
113
|
const aliasConfigPaths = filterIgnored(
|
|
91
114
|
fg.sync(['**/tsconfig*.json', '**/jsconfig*.json'], {
|
|
92
115
|
cwd: srcDir,
|
|
@@ -121,16 +144,35 @@ export function discoverWorkspace(srcDir) {
|
|
|
121
144
|
.../** @type {object | undefined} */ (parentConfig.compilerOptions),
|
|
122
145
|
.../** @type {object | undefined} */ (config.compilerOptions)
|
|
123
146
|
},
|
|
124
|
-
extends: parentConfig.extends
|
|
147
|
+
extends: /** @type {{extends?: unknown}} */ (parentConfig).extends
|
|
125
148
|
};
|
|
126
149
|
current = parentPath;
|
|
127
150
|
}
|
|
128
151
|
const compilerOptions = /** @type {{ paths?: Record<string, unknown> } | undefined} */ (config.compilerOptions);
|
|
129
152
|
const paths = compilerOptions?.paths;
|
|
130
153
|
if (paths) {
|
|
131
|
-
|
|
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] });
|
|
132
173
|
}
|
|
133
174
|
}
|
|
175
|
+
const aliasScope = buildAliasScope(aliasLayers);
|
|
134
176
|
|
|
135
177
|
const sourceFiles = filterIgnored(
|
|
136
178
|
fg.sync(SOURCE_GLOB, {
|
|
@@ -142,7 +184,73 @@ export function discoverWorkspace(srcDir) {
|
|
|
142
184
|
})
|
|
143
185
|
).sort();
|
|
144
186
|
|
|
145
|
-
|
|
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)}/`);
|
|
146
254
|
}
|
|
147
255
|
|
|
148
256
|
/**
|
|
@@ -154,19 +262,6 @@ function depNames(section) {
|
|
|
154
262
|
return Object.keys(/** @type {Record<string, unknown>} */ (section)).map((n) => n.toLowerCase());
|
|
155
263
|
}
|
|
156
264
|
|
|
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
265
|
/**
|
|
171
266
|
* @param {string} spec
|
|
172
267
|
* @param {string} fromDir
|