@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dependably/npm-check",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "A comprehensive tool for validating, migrating, and updating npm package-lock.json files across versions 1, 2, and 3.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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.aliasPrefixes);
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.aliasPrefixes);
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
@@ -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
  };
@@ -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';
@@ -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
- * @param {ReadonlySet<string>} [aliasPrefixes] tsconfig/jsconfig path-alias
65
- * bases; a specifier under one resolves to `{ kind: 'alias' }`.
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(aliasPrefixes = new Set()) {
68
- /** @type {ReadonlySet<string>} */
69
- this.aliasPrefixes = aliasPrefixes;
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.aliasPrefixes) {
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
+ }
@@ -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
+ }
@@ -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
- constructor(aliasPrefixes?: ReadonlySet<string>);
116
- readonly aliasPrefixes: ReadonlySet<string>;
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
- /** tsconfig/jsconfig paths alias bases; specifiers matching these are never package imports. */
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.0';
571
+ export const FACTS_SCHEMA_VERSION: '1.1';
486
572
 
487
573
  export class FactsError extends Error {
488
574
  constructor(code: string, message: string);
@@ -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. Both are `1.0` today; a field added to the facts document bumps its
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.0';
16
+ export const FACTS_SCHEMA_VERSION = '1.1';
@@ -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), the
5
- // tsconfig/jsconfig `paths` alias bases (specifiers under one are never a
6
- // package import), and the first-party source files themselves everything
7
- // the scan and the module-graph walk take as their starting point.
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
- const IGNORE_DIRS = [
21
- '**/node_modules/**',
22
- '**/.git/**',
23
- '**/dist/**',
24
- '**/build/**',
25
- '**/out/**',
26
- '**/coverage/**',
27
- '**/.next/**',
28
- '**/.turbo/**',
29
- '**/vendor/**'
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 gitignore = loadGitignore(srcDir);
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
- for (const key of Object.keys(paths)) aliasPrefixes.add(aliasBaseFromPathsKey(key));
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
- return { firstPartyNames, depScopes, aliasPrefixes, sourceFiles, diagnostics };
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