@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,201 @@
1
+ // src/facts/collect.js
2
+ // One call that gathers every import fact the modules under `src/facts/` can
3
+ // establish about a tree: the workspace (manifests, aliases, source files),
4
+ // every first-party file's scan with each site resolved to the installed
5
+ // copy it loads, the module graph through node_modules, the lockfile graph,
6
+ // and — load-bearing — the list of everything that was NOT read.
7
+ //
8
+ // It reports; it never judges. There is no "reachable" here, no verdict
9
+ // vocabulary at all: a consumer that owns those words (sbom-reach's npm
10
+ // analyzer) builds them from these facts. What this module guarantees is
11
+ // that the facts are complete OR say where they are not (`unanalyzable`):
12
+ // a file that could not be read used to be a silent `continue` in the
13
+ // consumer, and "nothing imports it" then rested on a file nobody read.
14
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
15
+ import { join, relative, resolve, sep } from 'node:path';
16
+ import { discoverLockfileGraphs } from './lockfile-graph.js';
17
+ import { walkModuleGraph } from './modulegraph.js';
18
+ import { ModuleResolver } from './resolve.js';
19
+ import { scanSource } from './scan.js';
20
+ import { specifierToPackage } from './specifier.js';
21
+ import { loadTypeScript } from './ts.js';
22
+ import { discoverWorkspace } from './workspace.js';
23
+
24
+ /** @typedef {import('./types.d.ts').CollectOptions} CollectOptions */
25
+ /** @typedef {import('./types.d.ts').FirstPartyFile} FirstPartyFile */
26
+ /** @typedef {import('./types.d.ts').ImportFacts} ImportFacts */
27
+ /** @typedef {import('./types.d.ts').ModuleGraph} ModuleGraph */
28
+ /** @typedef {import('./types.d.ts').ResolvedSite} ResolvedSite */
29
+ /** @typedef {import('./types.d.ts').UnanalyzableEntry} UnanalyzableEntry */
30
+
31
+ /**
32
+ * Relativizes paths against `srcDir` — realpath-aware. First-party files are
33
+ * spelled under `srcDir` as given; resolved node_modules files are realpath'd
34
+ * (pnpm symlinks, macOS `/var` → `/private/var`, `/tmp` → `/private/tmp`),
35
+ * and `srcDir` as given may not be. Relativize against whichever spelling of
36
+ * `srcDir` the file actually sits under: the one that needs FEWER `..`
37
+ * segments. "Prefer the realpath only when it lands inside the tree" was the
38
+ * first rule and it was wrong — with node_modules hoisted ABOVE the target
39
+ * and a symlinked prefix on `srcDir`, both spellings start with `..`, and the
40
+ * as-given one is a `../../../..`-to-root chain followed by the whole
41
+ * absolute path (adversarial review), poisoning every path in the document.
42
+ * Always POSIX separators: the document must compare across machines. The
43
+ * tree itself relativizes to `''`, exactly as sbom-reach's `relOf` does — a
44
+ * consumer emits that spelling verbatim, so it must not change.
45
+ * @param {string} srcDir
46
+ * @param {string} realSrcDir
47
+ * @returns {(file: string) => string}
48
+ */
49
+ export function makeRelOf(srcDir, realSrcDir) {
50
+ return (file) => {
51
+ const direct = relative(srcDir, file);
52
+ if (realSrcDir === srcDir) return direct.split(sep).join('/');
53
+ const viaReal = relative(realSrcDir, file);
54
+ const chosen = parentSegments(viaReal) < parentSegments(direct) ? viaReal : direct;
55
+ return chosen.split(sep).join('/');
56
+ };
57
+ }
58
+
59
+ /**
60
+ * How many leading `..` segments a relative path climbs through.
61
+ * @param {string} rel
62
+ * @returns {number}
63
+ */
64
+ function parentSegments(rel) {
65
+ let n = 0;
66
+ for (const part of rel.split(sep)) {
67
+ if (part !== '..') break;
68
+ n++;
69
+ }
70
+ return n;
71
+ }
72
+
73
+ /**
74
+ * Collect the import facts of the tree at `srcDir`.
75
+ *
76
+ * @param {string} srcDir - the tree to describe (relative paths resolve against cwd)
77
+ * @param {CollectOptions} [options]
78
+ * @returns {ImportFacts}
79
+ * @throws {import('./errors.js').FactsError} `TYPESCRIPT_MISSING` before any
80
+ * work is done, when the optional `typescript` peer is not installed.
81
+ */
82
+ export function collectImportFacts(srcDir, options = {}) {
83
+ // Fail before touching the tree: every other module here parses with the
84
+ // compiler, so there is nothing partial worth returning without it.
85
+ loadTypeScript();
86
+ const { moduleGraph = true, maxFiles, maxFileBytes, scan = scanSource } = options;
87
+
88
+ const absSrcDir = resolve(srcDir);
89
+ let realSrcDir = absSrcDir;
90
+ try {
91
+ realSrcDir = realpathSync(absSrcDir);
92
+ } catch {
93
+ // keep as given
94
+ }
95
+ const relOf = makeRelOf(absSrcDir, realSrcDir);
96
+
97
+ const workspace = discoverWorkspace(absSrcDir);
98
+ const resolver = new ModuleResolver(workspace.aliasScope);
99
+
100
+ /** @type {UnanalyzableEntry[]} */
101
+ const unanalyzable = [];
102
+ /** @type {FirstPartyFile[]} */
103
+ const files = [];
104
+ let dynamicUnknownTotal = 0;
105
+
106
+ for (const file of workspace.sourceFiles) {
107
+ const rel = relOf(file);
108
+ /** @type {string} */
109
+ let content;
110
+ try {
111
+ content = readFileSync(file, 'utf8');
112
+ } catch (err) {
113
+ // The consumer used to `continue` here silently. A file nobody read is
114
+ // a file whose imports are unknown, and it must be listed. The reason
115
+ // is the error CODE, never Node's message — that carries the absolute
116
+ // path, and reasons must stay machine-independent (`file` says where).
117
+ unanalyzable.push({ file: rel, kind: 'file', reason: `unreadable: ${errorCode(err)}` });
118
+ continue;
119
+ }
120
+ const result = scan(rel, content);
121
+ dynamicUnknownTotal += result.dynamicUnknown;
122
+ if (result.parseErrors && result.parseErrors.length > 0) {
123
+ // Sites collected before/around the problem are still reported; what
124
+ // this entry says is that their absence for some package is not a
125
+ // clean negative.
126
+ unanalyzable.push({ file: rel, kind: 'file-partial', reason: result.parseErrors.join('; ') });
127
+ }
128
+ /** @type {ResolvedSite[]} */
129
+ const sites = result.sites.map((site) => {
130
+ const pkg = specifierToPackage(site.specifier, workspace.aliasScope.for(file));
131
+ // Which installed copy does this statement load? Version-accurate
132
+ // attribution is the consumer's, but the fact — the copy the resolver
133
+ // lands in — is established here, once, with the same resolver the
134
+ // graph walk uses.
135
+ const resolution = resolver.resolve(file, site.specifier, site.kind === 'require' ? 'require' : 'import');
136
+ const installed =
137
+ (resolution.kind === 'file' || resolution.kind === 'asset') && resolution.pkg !== undefined
138
+ ? {
139
+ name: resolution.pkg.name,
140
+ dirName: resolution.pkg.dirName,
141
+ version: resolution.pkg.version,
142
+ root: resolution.pkg.root
143
+ }
144
+ : undefined;
145
+ return { ...site, package: pkg, ...(installed ? { installed } : {}) };
146
+ });
147
+ files.push({ file, rel, scan: result, sites });
148
+ }
149
+
150
+ /** @type {ModuleGraph | undefined} */
151
+ let graph;
152
+ let nodeModulesMissing = false;
153
+ if (moduleGraph) {
154
+ graph = walkModuleGraph({
155
+ srcDir: absSrcDir,
156
+ resolver,
157
+ roots: files.map((f) => ({ file: f.file, scan: f.scan })),
158
+ scan,
159
+ ...(maxFiles !== undefined ? { maxFiles } : {}),
160
+ ...(maxFileBytes !== undefined ? { maxFileBytes } : {})
161
+ });
162
+ nodeModulesMissing = graph.reached.size === 0 && graph.unresolved > 0 && !existsSync(join(absSrcDir, 'node_modules'));
163
+ for (const entry of graph.unanalyzable) {
164
+ unanalyzable.push({ file: relOf(entry.file), kind: 'node-modules-file', reason: entry.reason });
165
+ }
166
+ if (graph.truncated) {
167
+ unanalyzable.push({
168
+ file: 'node_modules',
169
+ kind: 'walk',
170
+ reason:
171
+ `file budget ${graph.filesParsed} reached; ${graph.filesPastBudget} resolved file(s) past that frontier ` +
172
+ 'were not parsed, so packages they load are unobserved, not absent'
173
+ });
174
+ }
175
+ }
176
+
177
+ const lockfile = discoverLockfileGraphs(absSrcDir);
178
+
179
+ return {
180
+ srcDir: absSrcDir,
181
+ realSrcDir,
182
+ workspace,
183
+ files,
184
+ graph,
185
+ nodeModulesMissing,
186
+ lockfile,
187
+ unanalyzable,
188
+ dynamicUnknownTotal
189
+ };
190
+ }
191
+
192
+ /**
193
+ * A path-free spelling of an I/O failure: the `code` (`EACCES`, `EISDIR`,
194
+ * `ENOENT`…) when there is one, else the constructor name.
195
+ * @param {unknown} err
196
+ * @returns {string}
197
+ */
198
+ export function errorCode(err) {
199
+ if (err && typeof err === 'object' && 'code' in err && typeof err.code === 'string') return err.code;
200
+ return err instanceof Error ? err.name : 'error';
201
+ }
@@ -0,0 +1,151 @@
1
+ // src/facts/document.js
2
+ // The JSON shape of an import-facts document: the in-memory `ImportFacts`
3
+ // (Maps, Sets, absolute paths) rendered as plain data a consumer can parse
4
+ // on any machine — Maps and Sets become sorted arrays, every absolute path
5
+ // becomes a target-relative POSIX path, and the module-graph keys keep their
6
+ // `\0` separator with the path half relativized the same way, so a key in
7
+ // `chain`/`fromPackage` still matches its `reached` entry.
8
+ //
9
+ // Language facts only: no purls, no verdicts, no severities. The envelope
10
+ // identity (`tool`, `toolVersion`, `schemaVersion`, `documentType`, `target`)
11
+ // is added by `schema.js`'s `buildFactsEnvelope`; this module produces the
12
+ // `summary` and the body sections.
13
+ import { makeRelOf } from './collect.js';
14
+
15
+ /** @typedef {import('./types.d.ts').ImportFacts} ImportFacts */
16
+ /** @typedef {import('./types.d.ts').FactsDocumentBody} FactsDocumentBody */
17
+ /** @typedef {import('./types.d.ts').FactsSummary} FactsSummary */
18
+ /** @typedef {import('./types.d.ts').ReachedPackage} ReachedPackage */
19
+ /** @typedef {import('./types.d.ts').DocumentReachedPackage} DocumentReachedPackage */
20
+
21
+ /**
22
+ * Render collected facts as the document body: `summary` plus every section.
23
+ * `target` is recorded by the envelope, not here; paths are relative to the
24
+ * facts' own `srcDir` (which is the target the CLI scanned).
25
+ *
26
+ * @param {ImportFacts} facts
27
+ * @param {{ exitCode?: number }} [options] `exitCode` lands in `summary.exitCode`
28
+ * and MUST equal the process exit code the run will return (0 here — a
29
+ * successful scan never gates).
30
+ * @returns {FactsDocumentBody}
31
+ */
32
+ export function factsDocument(facts, options = {}) {
33
+ const exitCode = options.exitCode ?? 0;
34
+ const relOf = makeRelOf(facts.srcDir, facts.realSrcDir);
35
+ /** @param {string} key */
36
+ const relKey = (key) => {
37
+ const nul = key.indexOf('\0');
38
+ return nul === -1 ? key : `${key.slice(0, nul)}\0${relOf(key.slice(nul + 1))}`;
39
+ };
40
+
41
+ const imports = facts.files.map((f) => ({
42
+ file: f.rel,
43
+ dynamicUnknown: f.scan.dynamicUnknown,
44
+ parseErrors: f.scan.parseErrors ?? [],
45
+ sites: f.sites.map((s) => ({
46
+ specifier: s.specifier,
47
+ package: s.package ?? null,
48
+ line: s.line,
49
+ snippet: s.snippet,
50
+ kind: s.kind,
51
+ bindings: s.bindings ?? [],
52
+ referenced: s.referenced ?? [],
53
+ opaque: s.opaque === true,
54
+ ...(s.installed ? { installed: { ...s.installed, root: relOf(s.installed.root) } } : {})
55
+ }))
56
+ }));
57
+
58
+ const graph = facts.graph;
59
+ /** @type {DocumentReachedPackage[]} */
60
+ const reached = graph
61
+ ? [...graph.reached.values()]
62
+ .map((entry) => ({
63
+ key: relKey(entry.key),
64
+ name: entry.name,
65
+ dirName: entry.dirName,
66
+ version: entry.version,
67
+ root: relOf(entry.root),
68
+ chain: entry.chain.map(relKey),
69
+ dynamic: entry.dynamic,
70
+ incomplete: entry.incomplete,
71
+ importers: entry.importers.map((imp) => ({
72
+ file: relOf(imp.file),
73
+ line: imp.line,
74
+ snippet: imp.snippet,
75
+ kind: imp.kind,
76
+ bindings: imp.bindings ?? [],
77
+ referenced: imp.referenced ?? [],
78
+ opaque: imp.opaque === true,
79
+ fromPackage: imp.fromPackage === undefined ? null : relKey(imp.fromPackage)
80
+ }))
81
+ }))
82
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
83
+ : [];
84
+ const unresolvedByName = graph
85
+ ? [...graph.unresolvedByName]
86
+ .map(([pkg, sites]) => ({
87
+ package: pkg,
88
+ sites: sites.map((s) => ({ file: relOf(s.file), reason: s.reason }))
89
+ }))
90
+ .sort((a, b) => (a.package < b.package ? -1 : a.package > b.package ? 1 : 0))
91
+ : [];
92
+
93
+ const moduleGraph = {
94
+ enabled: graph !== undefined,
95
+ filesParsed: graph ? graph.filesParsed : 0,
96
+ filesSkippedForSize: graph ? graph.filesSkippedForSize : 0,
97
+ unresolved: graph ? graph.unresolved : 0,
98
+ truncated: graph ? graph.truncated : false,
99
+ nodeModulesMissing: facts.nodeModulesMissing,
100
+ weakPackages: graph ? graph.weakPackages : [],
101
+ reached,
102
+ unresolvedByName
103
+ };
104
+
105
+ const ws = facts.workspace;
106
+ const workspace = {
107
+ firstPartyNames: [...ws.firstPartyNames].sort(),
108
+ depScopes: [...ws.depScopes]
109
+ .map(([name, scope]) => ({ name, scope }))
110
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
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)),
123
+ sourceFiles: ws.sourceFiles.length,
124
+ diagnostics: ws.diagnostics
125
+ };
126
+
127
+ const lockfile = {
128
+ files: facts.lockfile.files.map(relOf),
129
+ packages: facts.lockfile.packages,
130
+ rootDependencies: facts.lockfile.rootDependencies,
131
+ edges: facts.lockfile.edges,
132
+ diagnostics: facts.lockfile.diagnostics
133
+ };
134
+
135
+ /** @type {FactsSummary} */
136
+ const summary = {
137
+ scanned: ws.sourceFiles.length,
138
+ analyzed: facts.files.length,
139
+ unanalyzable: facts.unanalyzable.length,
140
+ imports: facts.files.reduce((n, f) => n + f.sites.length, 0),
141
+ moduleGraph: {
142
+ filesParsed: moduleGraph.filesParsed,
143
+ reached: reached.length,
144
+ unresolved: moduleGraph.unresolved,
145
+ truncated: moduleGraph.truncated
146
+ },
147
+ exitCode
148
+ };
149
+
150
+ return { summary, workspace, imports, moduleGraph, lockfile, unanalyzable: facts.unanalyzable };
151
+ }
@@ -0,0 +1,19 @@
1
+ // src/facts/errors.js
2
+ // The one error type the import-facts modules throw for a condition the caller
3
+ // is expected to handle by code rather than by message: `TYPESCRIPT_MISSING`
4
+ // (the optional `typescript` peer is not installed). Everything else — an
5
+ // unreadable file, an unparseable lockfile — is reported IN the facts, never
6
+ // thrown, because a facts document that aborts on one bad file has told the
7
+ // consumer nothing about the rest of the tree.
8
+
9
+ export class FactsError extends Error {
10
+ /**
11
+ * @param {string} code
12
+ * @param {string} message
13
+ */
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = 'FactsError';
17
+ this.code = code;
18
+ }
19
+ }
@@ -0,0 +1,37 @@
1
+ // src/facts/index.js
2
+ // Barrel for `@dependably/npm-check/facts` — the npm LANGUAGE-FACTS layer:
3
+ // per-file imports, bindings and referenced names; Node-style resolution;
4
+ // the walk through node_modules; lockfile graphs; workspace discovery.
5
+ // Facts, not findings: nothing exported here carries a severity, a verdict
6
+ // or a package identifier beyond the name and version on disk. The main
7
+ // barrel (`src/index.js`) deliberately does NOT re-export this — the
8
+ // lockfile commands must never load the TypeScript compiler.
9
+ //
10
+ // Precedent: pycheck's `--imports` document (its readme, "Import facts") —
11
+ // report not gate, a `documentType` discriminator, `unanalyzable[]`
12
+ // load-bearing, additive fields only.
13
+
14
+ // The facts document's own schema version — what `buildFactsEnvelope`
15
+ // (src/schema.js) writes as `schemaVersion`, a SEPARATE version line from
16
+ // the findings envelope's `SCHEMA_VERSION`. See version.js for why it is a
17
+ // leaf module rather than living here or in schema.js.
18
+ export { FACTS_SCHEMA_VERSION } from './version.js';
19
+
20
+ export { FactsError } from './errors.js';
21
+ export { loadTypeScript } from './ts.js';
22
+ export { collectImportFacts, makeRelOf } from './collect.js';
23
+ export { factsDocument } from './document.js';
24
+ export { scanSource } from './scan.js';
25
+ export { ModuleResolver, packageRootOf, resolveExports } from './resolve.js';
26
+ export { walkModuleGraph, packageKey, DEFAULT_MAX_FILES, DEFAULT_MAX_FILE_BYTES } from './modulegraph.js';
27
+ export {
28
+ discoverLockfileGraphs,
29
+ mergeDiscovered,
30
+ parsePackageLockJson,
31
+ parsePackageLockJsonGraph,
32
+ parsePnpmLockYaml,
33
+ parsePnpmLockYamlGraph
34
+ } from './lockfile-graph.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';