@trustify-da/trustify-da-javascript-client 0.3.0-ea.e12bc82 → 0.3.0-ea.f2d5d72

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.
Files changed (64) hide show
  1. package/README.md +191 -11
  2. package/dist/package.json +22 -9
  3. package/dist/src/analysis.d.ts +21 -5
  4. package/dist/src/analysis.js +71 -78
  5. package/dist/src/batch_opts.d.ts +24 -0
  6. package/dist/src/batch_opts.js +35 -0
  7. package/dist/src/cli.js +192 -8
  8. package/dist/src/cyclone_dx_sbom.d.ts +10 -2
  9. package/dist/src/cyclone_dx_sbom.js +32 -5
  10. package/dist/src/index.d.ts +128 -11
  11. package/dist/src/index.js +272 -7
  12. package/dist/src/license/index.d.ts +28 -0
  13. package/dist/src/license/index.js +100 -0
  14. package/dist/src/license/license_utils.d.ts +40 -0
  15. package/dist/src/license/license_utils.js +134 -0
  16. package/dist/src/license/licenses_api.d.ts +34 -0
  17. package/dist/src/license/licenses_api.js +98 -0
  18. package/dist/src/license/project_license.d.ts +20 -0
  19. package/dist/src/license/project_license.js +62 -0
  20. package/dist/src/oci_image/images.d.ts +4 -5
  21. package/dist/src/oci_image/utils.d.ts +4 -4
  22. package/dist/src/provider.d.ts +17 -5
  23. package/dist/src/provider.js +27 -5
  24. package/dist/src/providers/base_java.d.ts +3 -5
  25. package/dist/src/providers/base_javascript.d.ts +29 -7
  26. package/dist/src/providers/base_javascript.js +129 -22
  27. package/dist/src/providers/base_pyproject.d.ts +147 -0
  28. package/dist/src/providers/base_pyproject.js +279 -0
  29. package/dist/src/providers/golang_gomodules.d.ts +20 -13
  30. package/dist/src/providers/golang_gomodules.js +112 -114
  31. package/dist/src/providers/gomod_parser.d.ts +4 -0
  32. package/dist/src/providers/gomod_parser.js +16 -0
  33. package/dist/src/providers/java_gradle.d.ts +9 -3
  34. package/dist/src/providers/java_gradle.js +12 -2
  35. package/dist/src/providers/java_gradle_groovy.d.ts +1 -1
  36. package/dist/src/providers/java_gradle_kotlin.d.ts +1 -1
  37. package/dist/src/providers/java_maven.d.ts +12 -5
  38. package/dist/src/providers/java_maven.js +33 -5
  39. package/dist/src/providers/javascript_pnpm.d.ts +1 -1
  40. package/dist/src/providers/javascript_pnpm.js +2 -2
  41. package/dist/src/providers/manifest.d.ts +2 -0
  42. package/dist/src/providers/manifest.js +22 -4
  43. package/dist/src/providers/processors/yarn_berry_processor.js +82 -3
  44. package/dist/src/providers/python_controller.d.ts +5 -2
  45. package/dist/src/providers/python_controller.js +56 -58
  46. package/dist/src/providers/python_pip.d.ts +11 -4
  47. package/dist/src/providers/python_pip.js +47 -54
  48. package/dist/src/providers/python_poetry.d.ts +42 -0
  49. package/dist/src/providers/python_poetry.js +146 -0
  50. package/dist/src/providers/python_uv.d.ts +26 -0
  51. package/dist/src/providers/python_uv.js +118 -0
  52. package/dist/src/providers/requirements_parser.d.ts +6 -0
  53. package/dist/src/providers/requirements_parser.js +24 -0
  54. package/dist/src/providers/rust_cargo.d.ts +52 -0
  55. package/dist/src/providers/rust_cargo.js +614 -0
  56. package/dist/src/providers/tree-sitter-gomod.wasm +0 -0
  57. package/dist/src/providers/tree-sitter-requirements.wasm +0 -0
  58. package/dist/src/sbom.d.ts +10 -1
  59. package/dist/src/sbom.js +12 -2
  60. package/dist/src/tools.d.ts +22 -6
  61. package/dist/src/tools.js +56 -1
  62. package/dist/src/workspace.d.ts +61 -0
  63. package/dist/src/workspace.js +256 -0
  64. package/package.json +23 -10
@@ -1,11 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import os from "node:os";
3
3
  import path from 'node:path';
4
+ import { getLicense } from '../license/license_utils.js';
4
5
  import Sbom from '../sbom.js';
5
- import { getCustom, getCustomPath, invokeCommand, toPurl, toPurlFromString } from "../tools.js";
6
+ import { getCustom, getCustomPath, invokeCommand, toPurl, toPurlFromString } from '../tools.js';
6
7
  import Manifest from './manifest.js';
7
- /** @typedef {import('../provider.js').Provider} Provider */
8
- /** @typedef {import('../provider.js').Provided} Provided */
8
+ /** @typedef {import('../provider').Provider} */
9
+ /** @typedef {import('../provider').Provided} Provided */
9
10
  /**
10
11
  * The ecosystem identifier for JavaScript/npm packages
11
12
  * @type {string}
@@ -96,13 +97,63 @@ export default class Base_javascript {
96
97
  return 'package.json' === manifestName;
97
98
  }
98
99
  /**
99
- * Checks if a required lock file exists in the same path as the manifest
100
+ * Walks up the directory tree from manifestDir looking for the lock file.
101
+ * Stops when the lock file is found, when a package.json with a "workspaces"
102
+ * field is encountered without a lock file (workspace root boundary), or
103
+ * when the filesystem root is reached.
104
+ *
105
+ * When TRUSTIFY_DA_WORKSPACE_DIR is set, checks only that directory (no walk-up).
106
+ *
107
+ * @param {string} manifestDir - The directory to start searching from
108
+ * @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR
109
+ * @returns {string|null} The directory containing the lock file, or null
110
+ * @protected
111
+ */
112
+ _isWorkspaceRoot(dir) {
113
+ if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
114
+ return true;
115
+ }
116
+ const pkgJsonPath = path.join(dir, 'package.json');
117
+ if (fs.existsSync(pkgJsonPath)) {
118
+ try {
119
+ const content = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
120
+ if (content.workspaces) {
121
+ return true;
122
+ }
123
+ }
124
+ catch (_) {
125
+ // ignore parse errors
126
+ }
127
+ }
128
+ return false;
129
+ }
130
+ _findLockFileDir(manifestDir, opts = {}) {
131
+ const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts);
132
+ if (workspaceDir) {
133
+ const dir = path.resolve(workspaceDir);
134
+ return fs.existsSync(path.join(dir, this._lockFileName())) ? dir : null;
135
+ }
136
+ let dir = path.resolve(manifestDir);
137
+ let parent = dir;
138
+ do {
139
+ dir = parent;
140
+ if (fs.existsSync(path.join(dir, this._lockFileName()))) {
141
+ return dir;
142
+ }
143
+ if (this._isWorkspaceRoot(dir)) {
144
+ return null;
145
+ }
146
+ parent = path.dirname(dir);
147
+ } while (parent !== dir);
148
+ return null;
149
+ }
150
+ /**
100
151
  * @param {string} manifestDir - The base directory where the manifest is located
152
+ * @param {Object} [opts={}] - optional; may contain TRUSTIFY_DA_WORKSPACE_DIR
101
153
  * @returns {boolean} True if the lock file exists
102
154
  */
103
- validateLockFile(manifestDir) {
104
- const lock = path.join(manifestDir, this._lockFileName());
105
- return fs.existsSync(lock);
155
+ validateLockFile(manifestDir, opts = {}) {
156
+ return this._findLockFileDir(manifestDir, opts) !== null;
106
157
  }
107
158
  /**
108
159
  * Provides content and content type for stack analysis
@@ -132,17 +183,42 @@ export default class Base_javascript {
132
183
  contentType: 'application/vnd.cyclonedx+json'
133
184
  };
134
185
  }
186
+ /**
187
+ * Read license from manifest (package.json). Reused by npm, pnpm, yarn.
188
+ * @param {string} manifestPath - path to package.json
189
+ * @returns {string|null}
190
+ */
191
+ readLicenseFromManifest(manifestPath) {
192
+ let manifestLicense;
193
+ try {
194
+ const content = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
195
+ if (typeof content.license === 'string') {
196
+ manifestLicense = content.license.trim() || null;
197
+ }
198
+ else if (Array.isArray(content.licenses) && content.licenses.length > 0) {
199
+ const first = content.licenses[0];
200
+ const name = first.type || first.name;
201
+ manifestLicense = (typeof name === 'string' ? name.trim() : null);
202
+ }
203
+ }
204
+ catch {
205
+ manifestLicense = null;
206
+ }
207
+ return getLicense(manifestLicense, manifestPath);
208
+ }
135
209
  /**
136
210
  * Builds the dependency tree for the project
137
211
  * @param {boolean} includeTransitive - Whether to include transitive dependencies
212
+ * @param {Object} [opts={}] - Configuration options; when `TRUSTIFY_DA_WORKSPACE_DIR` is set, commands run from workspace root
138
213
  * @returns {Object} The dependency tree
139
214
  * @protected
140
215
  */
141
- _buildDependencyTree(includeTransitive) {
216
+ _buildDependencyTree(includeTransitive, opts = {}) {
142
217
  this._version();
143
- let manifestDir = path.dirname(this.#manifest.manifestPath);
144
- this.#createLockFile(manifestDir);
145
- let output = this.#executeListCmd(includeTransitive, manifestDir);
218
+ const manifestDir = path.dirname(this.#manifest.manifestPath);
219
+ const cmdDir = this._findLockFileDir(manifestDir, opts) || manifestDir;
220
+ this.#createLockFile(cmdDir);
221
+ let output = this.#executeListCmd(includeTransitive, cmdDir);
146
222
  output = this._parseDepTreeOutput(output);
147
223
  return JSON.parse(output);
148
224
  }
@@ -153,14 +229,38 @@ export default class Base_javascript {
153
229
  * @private
154
230
  */
155
231
  #getSBOM(opts = {}) {
156
- const depsObject = this._buildDependencyTree(true);
232
+ const depsObject = this._buildDependencyTree(true, opts);
157
233
  let mainComponent = toPurl(purlType, this.#manifest.name, this.#manifest.version);
234
+ const license = this.readLicenseFromManifest(this.#manifest.manifestPath);
158
235
  let sbom = new Sbom();
159
- sbom.addRoot(mainComponent);
236
+ sbom.addRoot(mainComponent, license);
160
237
  this._addDependenciesToSbom(sbom, depsObject);
238
+ this.#ensurePeerAndOptionalDeps(sbom);
161
239
  sbom.filterIgnoredDeps(this.#manifest.ignored);
162
240
  return sbom.getAsJsonString(opts);
163
241
  }
242
+ /**
243
+ * Ensures peer and optional dependencies declared in the manifest are
244
+ * present in the SBOM, even when the package manager does not resolve them
245
+ * (e.g. yarn does not include peer deps in its dependency listing).
246
+ * @param {Sbom} sbom - The SBOM to supplement
247
+ * @private
248
+ */
249
+ #ensurePeerAndOptionalDeps(sbom) {
250
+ const rootPurl = toPurl(purlType, this.#manifest.name, this.#manifest.version);
251
+ const depSources = [this.#manifest.peerDependencies, this.#manifest.optionalDependencies];
252
+ for (const source of depSources) {
253
+ for (const [name, version] of Object.entries(source)) {
254
+ // Build the purl prefix for exact matching (e.g. "pkg:npm/minimist@"
255
+ // or "pkg:npm/%40hapi/joi@") to avoid substring false positives
256
+ const probe = toPurl(purlType, name, version);
257
+ const purlPrefix = probe.toString().replace(/@[^@]*$/, '@');
258
+ if (!sbom.checkDependsOnByPurlPrefix(rootPurl, purlPrefix)) {
259
+ sbom.addDependency(rootPurl, probe);
260
+ }
261
+ }
262
+ }
263
+ }
164
264
  /**
165
265
  * Recursively builds the Sbom from the JSON that npm listing returns
166
266
  * @param {Sbom} sbom - The SBOM object to add dependencies to
@@ -168,7 +268,10 @@ export default class Base_javascript {
168
268
  * @protected
169
269
  */
170
270
  _addDependenciesToSbom(sbom, depTree) {
171
- const dependencies = depTree["dependencies"] || {};
271
+ const dependencies = {
272
+ ...depTree["dependencies"],
273
+ ...depTree["optionalDependencies"],
274
+ };
172
275
  Object.entries(dependencies)
173
276
  .forEach(entry => {
174
277
  const [name, artifact] = entry;
@@ -204,10 +307,11 @@ export default class Base_javascript {
204
307
  * @private
205
308
  */
206
309
  #getDirectDependencySbom(opts = {}) {
207
- const depTree = this._buildDependencyTree(false);
310
+ const depTree = this._buildDependencyTree(false, opts);
208
311
  let mainComponent = toPurl(purlType, this.#manifest.name, this.#manifest.version);
312
+ const license = this.readLicenseFromManifest(this.#manifest.manifestPath);
209
313
  let sbom = new Sbom();
210
- sbom.addRoot(mainComponent);
314
+ sbom.addRoot(mainComponent, license);
211
315
  const rootDeps = this._getRootDependencies(depTree);
212
316
  const sortedDepsKeys = Array
213
317
  .from(rootDeps.keys())
@@ -217,6 +321,7 @@ export default class Base_javascript {
217
321
  const rootPurl = toPurlFromString(sbom.getRoot().purl);
218
322
  sbom.addDependency(rootPurl, rootDeps.get(key));
219
323
  }
324
+ this.#ensurePeerAndOptionalDeps(sbom);
220
325
  sbom.filterIgnoredDeps(this.#manifest.ignored);
221
326
  return sbom.getAsJsonString(opts);
222
327
  }
@@ -227,10 +332,14 @@ export default class Base_javascript {
227
332
  * @protected
228
333
  */
229
334
  _getRootDependencies(depTree) {
230
- if (!depTree.dependencies) {
335
+ const allDeps = {
336
+ ...depTree.dependencies,
337
+ ...depTree.optionalDependencies,
338
+ };
339
+ if (Object.keys(allDeps).length === 0) {
231
340
  return new Map();
232
341
  }
233
- return new Map(Object.entries(depTree.dependencies).map(([key, value]) => [key, toPurl(purlType, key, value.version)]));
342
+ return new Map(Object.entries(allDeps).map(([key, value]) => [key, toPurl(purlType, key, value.version)]));
234
343
  }
235
344
  /**
236
345
  * Executes the list command to get dependencies
@@ -241,7 +350,7 @@ export default class Base_javascript {
241
350
  */
242
351
  #executeListCmd(includeTransitive, manifestDir) {
243
352
  const listArgs = this._listCmdArgs(includeTransitive, manifestDir);
244
- return this.#invokeCommand(listArgs);
353
+ return this.#invokeCommand(listArgs, { cwd: manifestDir });
245
354
  }
246
355
  /**
247
356
  * Gets the version of the package manager
@@ -260,13 +369,11 @@ export default class Base_javascript {
260
369
  const originalDir = process.cwd();
261
370
  const isWindows = os.platform() === 'win32';
262
371
  if (isWindows) {
263
- // On Windows, --prefix flag doesn't work as expected
264
- // Instead of installing from the prefix folder, it installs from current working directory
265
372
  process.chdir(manifestDir);
266
373
  }
267
374
  try {
268
375
  const args = this._updateLockFileCmdArgs(manifestDir);
269
- this.#invokeCommand(args);
376
+ this.#invokeCommand(args, { cwd: manifestDir });
270
377
  }
271
378
  finally {
272
379
  if (isWindows) {
@@ -0,0 +1,147 @@
1
+ /** @typedef {{name: string, version: string, children: string[]}} GraphEntry */
2
+ /** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */
3
+ /** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
4
+ /** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
5
+ export default class Base_pyproject {
6
+ /**
7
+ * @param {string} manifestName
8
+ * @returns {boolean}
9
+ */
10
+ isSupported(manifestName: string): boolean;
11
+ /**
12
+ * @param {string} manifestDir
13
+ * @returns {boolean}
14
+ */
15
+ validateLockFile(manifestDir: string): boolean;
16
+ /**
17
+ * Read project license from pyproject.toml, with fallback to LICENSE file.
18
+ * @param {string} manifestPath
19
+ * @returns {string|null}
20
+ */
21
+ readLicenseFromManifest(manifestPath: string): string | null;
22
+ /**
23
+ * @param {string} manifest - path to pyproject.toml
24
+ * @param {Object} [opts={}]
25
+ * @returns {Promise<Provided>}
26
+ */
27
+ provideStack(manifest: string, opts?: any): Promise<Provided>;
28
+ /**
29
+ * @param {string} manifest - path to pyproject.toml
30
+ * @param {Object} [opts={}]
31
+ * @returns {Promise<Provided>}
32
+ */
33
+ provideComponent(manifest: string, opts?: any): Promise<Provided>;
34
+ /**
35
+ * @returns {string}
36
+ * @protected
37
+ */
38
+ protected _lockFileName(): string;
39
+ /**
40
+ * @returns {string}
41
+ * @protected
42
+ */
43
+ protected _cmdName(): string;
44
+ /**
45
+ * Resolve dependencies using the tool-specific command and parser.
46
+ * @param {string} manifestDir
47
+ * @param {object} parsed - parsed pyproject.toml
48
+ * @param {Object} opts
49
+ * @returns {Promise<DependencyData>}
50
+ * @protected
51
+ */
52
+ protected _getDependencyData(manifestDir: string, parsed: object, opts: any): Promise<DependencyData>;
53
+ /**
54
+ * Canonicalize a Python package name per PEP 503.
55
+ * @param {string} name
56
+ * @returns {string}
57
+ * @protected
58
+ */
59
+ protected _canonicalize(name: string): string;
60
+ /**
61
+ * Get the project name from pyproject.toml.
62
+ * @param {object} parsed
63
+ * @returns {string|null}
64
+ * @protected
65
+ */
66
+ protected _getProjectName(parsed: object): string | null;
67
+ /**
68
+ * Get the project version from pyproject.toml.
69
+ * @param {object} parsed
70
+ * @returns {string|null}
71
+ * @protected
72
+ */
73
+ protected _getProjectVersion(parsed: object): string | null;
74
+ /**
75
+ * Scan raw pyproject.toml text for dependencies with ignore markers.
76
+ * @param {string} manifestPath
77
+ * @returns {Set<string>}
78
+ * @protected
79
+ */
80
+ protected _getIgnoredDeps(manifestPath: string): Set<string>;
81
+ /**
82
+ * Build dependency tree from graph, starting from direct deps.
83
+ * @param {Map<string, GraphEntry>} graph
84
+ * @param {string[]} directDeps - canonical names of direct deps
85
+ * @param {Set<string>} ignoredDeps
86
+ * @param {boolean} includeTransitive
87
+ * @returns {DepTreeEntry[]}
88
+ * @protected
89
+ */
90
+ protected _buildDependencyTree(graph: Map<string, GraphEntry>, directDeps: string[], ignoredDeps: Set<string>, includeTransitive: boolean): DepTreeEntry[];
91
+ /**
92
+ * Recursively collect transitive dependencies.
93
+ * @param {Map<string, GraphEntry>} graph
94
+ * @param {string[]} childKeys
95
+ * @param {DepTreeEntry[]} result - mutated in place
96
+ * @param {Set<string>} ignoredDeps
97
+ * @param {Set<string>} visited
98
+ * @returns {void}
99
+ * @protected
100
+ */
101
+ protected _collectTransitive(graph: Map<string, GraphEntry>, childKeys: string[], result: DepTreeEntry[], ignoredDeps: Set<string>, visited: Set<string>): void;
102
+ /**
103
+ * @param {string} name
104
+ * @param {string} version
105
+ * @returns {PackageURL}
106
+ * @protected
107
+ */
108
+ protected _toPurl(name: string, version: string): PackageURL;
109
+ /**
110
+ * Recursively add a dependency and its transitive deps to the SBOM.
111
+ * @param {PackageURL} source
112
+ * @param {DepTreeEntry} dep
113
+ * @param {Sbom} sbom
114
+ * @returns {void}
115
+ * @private
116
+ */
117
+ private _addAllDependencies;
118
+ /**
119
+ * Create SBOM json string for a pyproject.toml project.
120
+ * @param {string} manifest - path to pyproject.toml
121
+ * @param {Object} opts
122
+ * @param {boolean} includeTransitive
123
+ * @returns {Promise<string>}
124
+ * @private
125
+ */
126
+ private _createSbom;
127
+ }
128
+ export type GraphEntry = {
129
+ name: string;
130
+ version: string;
131
+ children: string[];
132
+ };
133
+ export type DepTreeEntry = {
134
+ name: string;
135
+ version: string;
136
+ dependencies: DepTreeEntry[];
137
+ };
138
+ export type DependencyData = {
139
+ directDeps: string[];
140
+ graph: Map<string, GraphEntry>;
141
+ };
142
+ export type Provided = {
143
+ ecosystem: string;
144
+ content: string;
145
+ contentType: string;
146
+ };
147
+ import { PackageURL } from 'packageurl-js';
@@ -0,0 +1,279 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PackageURL } from 'packageurl-js';
4
+ import { parse as parseToml } from 'smol-toml';
5
+ import { getLicense } from '../license/license_utils.js';
6
+ import Sbom from '../sbom.js';
7
+ const ecosystem = 'pip';
8
+ const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore'];
9
+ const DEFAULT_ROOT_NAME = 'default-pip-root';
10
+ const DEFAULT_ROOT_VERSION = '0.0.0';
11
+ /** @typedef {{name: string, version: string, children: string[]}} GraphEntry */
12
+ /** @typedef {{name: string, version: string, dependencies: DepTreeEntry[]}} DepTreeEntry */
13
+ /** @typedef {{directDeps: string[], graph: Map<string, GraphEntry>}} DependencyData */
14
+ /** @typedef {{ecosystem: string, content: string, contentType: string}} Provided */
15
+ export default class Base_pyproject {
16
+ /**
17
+ * @param {string} manifestName
18
+ * @returns {boolean}
19
+ */
20
+ isSupported(manifestName) {
21
+ return 'pyproject.toml' === manifestName;
22
+ }
23
+ /**
24
+ * @param {string} manifestDir
25
+ * @returns {boolean}
26
+ */
27
+ validateLockFile(manifestDir) {
28
+ return fs.existsSync(path.join(manifestDir, this._lockFileName()));
29
+ }
30
+ /**
31
+ * Read project license from pyproject.toml, with fallback to LICENSE file.
32
+ * @param {string} manifestPath
33
+ * @returns {string|null}
34
+ */
35
+ readLicenseFromManifest(manifestPath) {
36
+ let fromManifest = null;
37
+ try {
38
+ let content = fs.readFileSync(manifestPath, 'utf-8');
39
+ let parsed = parseToml(content);
40
+ fromManifest = parsed.project?.license;
41
+ if (typeof fromManifest === 'object' && fromManifest != null) {
42
+ fromManifest = fromManifest.text || null;
43
+ }
44
+ if (!fromManifest) {
45
+ fromManifest = parsed.tool?.poetry?.license || null;
46
+ }
47
+ }
48
+ catch (_) {
49
+ // leave fromManifest as null
50
+ }
51
+ return getLicense(fromManifest, manifestPath);
52
+ }
53
+ /**
54
+ * @param {string} manifest - path to pyproject.toml
55
+ * @param {Object} [opts={}]
56
+ * @returns {Promise<Provided>}
57
+ */
58
+ async provideStack(manifest, opts = {}) {
59
+ return {
60
+ ecosystem,
61
+ content: await this._createSbom(manifest, opts, true),
62
+ contentType: 'application/vnd.cyclonedx+json'
63
+ };
64
+ }
65
+ /**
66
+ * @param {string} manifest - path to pyproject.toml
67
+ * @param {Object} [opts={}]
68
+ * @returns {Promise<Provided>}
69
+ */
70
+ async provideComponent(manifest, opts = {}) {
71
+ return {
72
+ ecosystem,
73
+ content: await this._createSbom(manifest, opts, false),
74
+ contentType: 'application/vnd.cyclonedx+json'
75
+ };
76
+ }
77
+ // --- abstract methods (subclasses must override) ---
78
+ /**
79
+ * @returns {string}
80
+ * @protected
81
+ */
82
+ _lockFileName() {
83
+ throw new TypeError('_lockFileName must be implemented');
84
+ }
85
+ /**
86
+ * @returns {string}
87
+ * @protected
88
+ */
89
+ _cmdName() {
90
+ throw new TypeError('_cmdName must be implemented');
91
+ }
92
+ /**
93
+ * Resolve dependencies using the tool-specific command and parser.
94
+ * @param {string} manifestDir
95
+ * @param {object} parsed - parsed pyproject.toml
96
+ * @param {Object} opts
97
+ * @returns {Promise<DependencyData>}
98
+ * @protected
99
+ */
100
+ // eslint-disable-next-line no-unused-vars
101
+ async _getDependencyData(manifestDir, parsed, opts) {
102
+ throw new TypeError('_getDependencyData must be implemented');
103
+ }
104
+ // --- shared helpers ---
105
+ /**
106
+ * Canonicalize a Python package name per PEP 503.
107
+ * @param {string} name
108
+ * @returns {string}
109
+ * @protected
110
+ */
111
+ _canonicalize(name) {
112
+ return name.toLowerCase().replace(/[-_.]+/g, '-');
113
+ }
114
+ /**
115
+ * Get the project name from pyproject.toml.
116
+ * @param {object} parsed
117
+ * @returns {string|null}
118
+ * @protected
119
+ */
120
+ _getProjectName(parsed) {
121
+ return parsed.project?.name || parsed.tool?.poetry?.name || null;
122
+ }
123
+ /**
124
+ * Get the project version from pyproject.toml.
125
+ * @param {object} parsed
126
+ * @returns {string|null}
127
+ * @protected
128
+ */
129
+ _getProjectVersion(parsed) {
130
+ return parsed.project?.version || parsed.tool?.poetry?.version || null;
131
+ }
132
+ /**
133
+ * Scan raw pyproject.toml text for dependencies with ignore markers.
134
+ * @param {string} manifestPath
135
+ * @returns {Set<string>}
136
+ * @protected
137
+ */
138
+ _getIgnoredDeps(manifestPath) {
139
+ let ignored = new Set();
140
+ let content = fs.readFileSync(manifestPath, 'utf-8');
141
+ let lines = content.split(/\r?\n/);
142
+ for (let line of lines) {
143
+ if (!IGNORE_MARKERS.some(m => line.includes(m))) {
144
+ continue;
145
+ }
146
+ // PEP 621 style: "requests>=2.25" #exhortignore
147
+ let pep621Match = line.match(/^\s*"([^"]+)"/);
148
+ if (pep621Match) {
149
+ let reqStr = pep621Match[1];
150
+ let nameMatch = reqStr.match(/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)/);
151
+ if (nameMatch) {
152
+ ignored.add(this._canonicalize(nameMatch[1]));
153
+ }
154
+ continue;
155
+ }
156
+ // Poetry style: requests = "^2.25" #exhortignore
157
+ let poetryMatch = line.match(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/);
158
+ if (poetryMatch) {
159
+ ignored.add(this._canonicalize(poetryMatch[1]));
160
+ }
161
+ }
162
+ return ignored;
163
+ }
164
+ /**
165
+ * Build dependency tree from graph, starting from direct deps.
166
+ * @param {Map<string, GraphEntry>} graph
167
+ * @param {string[]} directDeps - canonical names of direct deps
168
+ * @param {Set<string>} ignoredDeps
169
+ * @param {boolean} includeTransitive
170
+ * @returns {DepTreeEntry[]}
171
+ * @protected
172
+ */
173
+ _buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) {
174
+ let result = [];
175
+ for (let key of directDeps) {
176
+ if (ignoredDeps.has(key)) {
177
+ continue;
178
+ }
179
+ let entry = graph.get(key);
180
+ if (!entry) {
181
+ continue;
182
+ }
183
+ let depTree = [];
184
+ if (includeTransitive) {
185
+ let visited = new Set();
186
+ visited.add(key);
187
+ this._collectTransitive(graph, entry.children, depTree, ignoredDeps, visited);
188
+ }
189
+ result.push({ name: entry.name, version: entry.version, dependencies: depTree });
190
+ }
191
+ result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
192
+ return result;
193
+ }
194
+ /**
195
+ * Recursively collect transitive dependencies.
196
+ * @param {Map<string, GraphEntry>} graph
197
+ * @param {string[]} childKeys
198
+ * @param {DepTreeEntry[]} result - mutated in place
199
+ * @param {Set<string>} ignoredDeps
200
+ * @param {Set<string>} visited
201
+ * @returns {void}
202
+ * @protected
203
+ */
204
+ _collectTransitive(graph, childKeys, result, ignoredDeps, visited) {
205
+ for (let childKey of childKeys) {
206
+ let canonKey = this._canonicalize(childKey);
207
+ if (ignoredDeps.has(canonKey)) {
208
+ continue;
209
+ }
210
+ if (visited.has(canonKey)) {
211
+ continue;
212
+ }
213
+ visited.add(canonKey);
214
+ let entry = graph.get(canonKey);
215
+ if (!entry) {
216
+ continue;
217
+ }
218
+ let childDeps = [];
219
+ this._collectTransitive(graph, entry.children, childDeps, ignoredDeps, visited);
220
+ result.push({ name: entry.name, version: entry.version, dependencies: childDeps });
221
+ }
222
+ result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
223
+ }
224
+ /**
225
+ * @param {string} name
226
+ * @param {string} version
227
+ * @returns {PackageURL}
228
+ * @protected
229
+ */
230
+ _toPurl(name, version) {
231
+ return new PackageURL('pypi', undefined, name, version, undefined, undefined);
232
+ }
233
+ /**
234
+ * Recursively add a dependency and its transitive deps to the SBOM.
235
+ * @param {PackageURL} source
236
+ * @param {DepTreeEntry} dep
237
+ * @param {Sbom} sbom
238
+ * @returns {void}
239
+ * @private
240
+ */
241
+ _addAllDependencies(source, dep, sbom) {
242
+ let targetPurl = this._toPurl(dep.name, dep.version);
243
+ sbom.addDependency(source, targetPurl);
244
+ if (dep.dependencies && dep.dependencies.length > 0) {
245
+ dep.dependencies.forEach(child => this._addAllDependencies(this._toPurl(dep.name, dep.version), child, sbom));
246
+ }
247
+ }
248
+ /**
249
+ * Create SBOM json string for a pyproject.toml project.
250
+ * @param {string} manifest - path to pyproject.toml
251
+ * @param {Object} opts
252
+ * @param {boolean} includeTransitive
253
+ * @returns {Promise<string>}
254
+ * @private
255
+ */
256
+ async _createSbom(manifest, opts, includeTransitive) {
257
+ let manifestDir = path.dirname(manifest);
258
+ let content = fs.readFileSync(manifest, 'utf-8');
259
+ let parsed = parseToml(content);
260
+ let { directDeps, graph } = await this._getDependencyData(manifestDir, parsed, opts);
261
+ let ignoredDeps = this._getIgnoredDeps(manifest);
262
+ let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive);
263
+ let sbom = new Sbom();
264
+ let rootName = this._getProjectName(parsed) || DEFAULT_ROOT_NAME;
265
+ let rootVersion = this._getProjectVersion(parsed) || DEFAULT_ROOT_VERSION;
266
+ let rootPurl = this._toPurl(rootName, rootVersion);
267
+ let license = this.readLicenseFromManifest(manifest);
268
+ sbom.addRoot(rootPurl, license);
269
+ dependencies.forEach(dep => {
270
+ if (includeTransitive) {
271
+ this._addAllDependencies(rootPurl, dep, sbom);
272
+ }
273
+ else {
274
+ sbom.addDependency(rootPurl, this._toPurl(dep.name, dep.version));
275
+ }
276
+ });
277
+ return sbom.getAsJsonString(opts);
278
+ }
279
+ }