Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ build
target
.npmrc
src/providers/*.wasm
*.egg-info
Comment thread
ruromero marked this conversation as resolved.
99 changes: 99 additions & 0 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Coding Conventions

<!-- This file documents project-specific coding standards for exhort-javascript-api. -->

## Language and Framework

- **Primary Language**: JavaScript (ES modules, `"type": "module"` in package.json)
- **TypeScript**: Configuration present but code is primarily JavaScript with JSDoc
- **Node.js**: Requires Node >= 20.0.0, npm >= 11.5.1
- **CLI**: `yargs` for command-line argument parsing
- **Parsing Libraries**: `fast-xml-parser`, `fast-toml`, `smol-toml`, `tree-sitter-requirements`

## Code Style

- **Linter**: ESLint with recommended config + editorconfig + import plugins
- **Indentation**: Tabs (4 spaces for YAML/Markdown)
- **Line endings**: LF
- **Max line length**: 100 (120 for Markdown)
- **Charset**: UTF-8, final newline, trim trailing whitespace
- **Import ordering** (ESLint enforced): builtin, external, internal, parent, sibling, index — alphabetical within groups
- **Strict equality**: `eqeqeq: ["warn", "always", {"null": "never"}]`
- **Curly braces**: Required (`curly: "warn"`)
- **No throw literals**: `no-throw-literal: "warn"`
- **No Prettier** — ESLint + EditorConfig handle formatting

## Naming Conventions

- **Classes**: PascalCase with underscore-separated language names (`Java_maven`, `Base_java`, `Javascript_npm`)
- **Files**: snake_case for providers (`base_java.js`, `javascript_npm.js`, `python_pip.js`)
- **Test files**: `*.test.js` suffix (`analysis.test.js`, `provider.test.js`)
- **Functions/Methods**: camelCase (`provideComponent()`, `provideStack()`, `validateLockFile()`)
- **Variables**: camelCase (`manifestPath`, `backendUrl`)
- **Constants**: UPPER_SNAKE_CASE (`ecosystem_maven`, `DEFAULT_WORKSPACE_DISCOVERY_IGNORE`)
- **Private class fields**: `#` prefix (`#manifest`, `#cmd`, `#ecosystem`)
- **Protected methods**: `_` prefix (`_lockFileName()`, `_cmdName()`, `_listCmdArgs()`)

## File Organization

```
src/
├── index.js # Main export
├── cli.js # CLI entry point
├── analysis.js # API request handling
├── provider.js # Provider matching logic
├── workspace.js # Workspace discovery
├── tools.js # Utilities
├── sbom.js # SBOM handling
├── cyclone_dx_sbom.js # CycloneDX SBOM generation
├── providers/ # Ecosystem providers
│ ├── base_java.js
│ ├── base_javascript.js
│ ├── java_maven.js
│ ├── javascript_npm.js
│ ├── python_pip.js
│ ├── rust_cargo.js
│ └── processors/ # Specialized processors
├── license/ # License detection
└── oci_image/ # OCI image analysis

test/
├── analysis.test.js
├── provider.test.js
├── tools.test.js
└── providers/ # Provider-specific tests
```

## Error Handling

- **Throw Error objects**: `throw new Error("message")`, `throw new TypeError("message")`
- **No custom error classes** — uses built-in `Error` and `TypeError`
- **HTTP errors**: Check `resp.status`, throw with status code and response text
- **Async errors**: Bubble up naturally via async/await (no blanket try-catch)
- **Validation errors**: Thrown early with descriptive context (manifest type, lock file)

## Testing Conventions

- **Framework**: Mocha with TDD UI (`suite()` / `test()`)
- **Assertions**: Chai with `expect()` syntax
- **Mocking**: Sinon for stubs; MSW (Mock Service Worker) for HTTP mocking
- **Module mocking**: `esmock` with experimental loader
- **Coverage**: C8 with 82% line coverage requirement
- **Test patterns**: `expect(res).to.deep.equal(...)`, `expect(() => ...).to.throw('message')`
- **Higher-order setup**: Functions like `interceptAndRun()` for test setup/teardown
- **Prefer real tool invocations over env var overrides**: Tests should call the actual ecosystem tools (pip, uv, poetry, mvn, npm, etc.) rather than injecting pre-recorded output via `TRUSTIFY_DA_*` environment variables. The CI environment has these tools available. Env var overrides (`TRUSTIFY_DA_PIP_REPORT`, `TRUSTIFY_DA_UV_EXPORT`, etc.) exist for users who lack the tool locally, but tests should exercise the real tool path to catch integration issues.
- **Golden SBOM files for every test fixture**: Every provider test fixture directory must include `expected_stack_sbom.json` and `expected_component_sbom.json` golden files. Tests must use the `SBOM_CASES` pattern to do a full `deep.equal` comparison of the provider output against these golden files. Manual partial assertions (e.g. checking a single component name) are not a substitute — they may be added as supplementary tests but never as the only verification for a fixture.

## Commit Messages

- Likely Conventional Commits format
- DCO (Developer Certificate of Origin) required
- Semantic versioning (`0.3.0` in package.json)

## Dependencies

- **Package manager**: npm with `package-lock.json`
- **Module system**: ES modules with explicit `.js` extensions in relative imports
- **Import convention**: `import fs from 'node:fs'` (node: protocol for built-ins)
- **Environment variables**: Prefixed with `TRUSTIFY_DA_` (e.g., `TRUSTIFY_DA_MVN_PATH`, `TRUSTIFY_DA_TOKEN`, `TRUSTIFY_DA_DEBUG`)
- **Multi-ecosystem support**: npm, pnpm, yarn, Maven, Gradle, pip, cargo, Go modules, Docker/Podman
2 changes: 2 additions & 0 deletions src/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Javascript_npm from './providers/javascript_npm.js';
import Javascript_pnpm from './providers/javascript_pnpm.js';
import Javascript_yarn from './providers/javascript_yarn.js';
import pythonPipProvider from './providers/python_pip.js'
import Python_pip_pyproject from './providers/python_pip_pyproject.js'
import Python_poetry from './providers/python_poetry.js'
import Python_uv from './providers/python_uv.js'
import rustCargoProvider from './providers/rust_cargo.js'
Expand All @@ -30,6 +31,7 @@ export const availableProviders = [
pythonPipProvider,
new Python_poetry(),
new Python_uv(),
new Python_pip_pyproject(),
rustCargoProvider]

/**
Expand Down
113 changes: 38 additions & 75 deletions src/providers/base_pyproject.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,64 +250,27 @@ export default class Base_pyproject {
}

/**
* Build dependency tree from graph, starting from direct deps.
* Compute the set of graph nodes reachable from direct deps, excluding ignored.
* @param {Map<string, GraphEntry>} graph
* @param {string[]} directDeps - canonical names of direct deps
* @param {string[]} directDeps
* @param {Set<string>} ignoredDeps
* @param {boolean} includeTransitive
* @returns {DepTreeEntry[]}
* @returns {Set<string>}
* @protected
*/
_buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive) {
let result = []

for (let key of directDeps) {
if (ignoredDeps.has(key)) { continue }

let entry = graph.get(key)
if (!entry) { continue }

let depTree = []
if (includeTransitive) {
let visited = new Set()
visited.add(key)
this._collectTransitive(graph, entry.children, depTree, ignoredDeps, visited)
_reachableNodes(graph, directDeps, ignoredDeps) {
let reachable = new Set()
let queue = directDeps.filter(k => !ignoredDeps.has(k) && graph.has(k))
while (queue.length > 0) {
let key = queue.shift()
if (reachable.has(key)) { continue }
reachable.add(key)
for (let child of graph.get(key).children) {
if (!ignoredDeps.has(child) && graph.has(child) && !reachable.has(child)) {
queue.push(child)
}
}

result.push({ name: entry.name, version: entry.version, dependencies: depTree })
}

result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
return result
}

/**
* Recursively collect transitive dependencies.
* @param {Map<string, GraphEntry>} graph
* @param {string[]} childKeys
* @param {DepTreeEntry[]} result - mutated in place
* @param {Set<string>} ignoredDeps
* @param {Set<string>} visited
* @returns {void}
* @protected
*/
_collectTransitive(graph, childKeys, result, ignoredDeps, visited) {
for (let childKey of childKeys) {
let canonKey = this._canonicalize(childKey)
if (ignoredDeps.has(canonKey)) { continue }
if (visited.has(canonKey)) { continue }
visited.add(canonKey)

let entry = graph.get(canonKey)
if (!entry) { continue }

let childDeps = []
this._collectTransitive(graph, entry.children, childDeps, ignoredDeps, visited)

result.push({ name: entry.name, version: entry.version, dependencies: childDeps })
}

result.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
return reachable
}

/**
Expand All @@ -320,22 +283,6 @@ export default class Base_pyproject {
return new PackageURL('pypi', undefined, name, version, undefined, undefined)
}

/**
* Recursively add a dependency and its transitive deps to the SBOM.
* @param {PackageURL} source
* @param {DepTreeEntry} dep
* @param {Sbom} sbom
* @returns {void}
* @private
*/
_addAllDependencies(source, dep, sbom) {
let targetPurl = this._toPurl(dep.name, dep.version)
sbom.addDependency(source, targetPurl)
if (dep.dependencies && dep.dependencies.length > 0) {
dep.dependencies.forEach(child => this._addAllDependencies(this._toPurl(dep.name, dep.version), child, sbom))
}
}

/**
* Create SBOM json string for a pyproject.toml project.
* @param {string} manifest - path to pyproject.toml
Expand All @@ -353,7 +300,6 @@ export default class Base_pyproject {
let { directDeps, graph } = await this._getDependencyData(manifestDir, workspaceDir, parsed, opts)

let ignoredDeps = this._getIgnoredDeps(manifest)
let dependencies = this._buildDependencyTree(graph, directDeps, ignoredDeps, includeTransitive)

let sbom = new Sbom()
let rootName = this._getProjectName(parsed) || DEFAULT_ROOT_NAME
Expand All @@ -362,13 +308,30 @@ export default class Base_pyproject {
let license = this.readLicenseFromManifest(manifest)
sbom.addRoot(rootPurl, license)

dependencies.forEach(dep => {
if (includeTransitive) {
this._addAllDependencies(rootPurl, dep, sbom)
} else {
sbom.addDependency(rootPurl, this._toPurl(dep.name, dep.version))
if (includeTransitive) {
let reachable = this._reachableNodes(graph, directDeps, ignoredDeps)
for (let key of directDeps) {
if (!reachable.has(key)) { continue }
let entry = graph.get(key)
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
}
})
for (let [key, entry] of graph) {
if (!reachable.has(key)) { continue }
let parentPurl = this._toPurl(entry.name, entry.version)
for (let child of entry.children) {
if (!reachable.has(child)) { continue }
let childEntry = graph.get(child)
sbom.addDependency(parentPurl, this._toPurl(childEntry.name, childEntry.version))
}
}
} else {
for (let key of directDeps) {
if (ignoredDeps.has(key)) { continue }
let entry = graph.get(key)
if (!entry) { continue }
sbom.addDependency(rootPurl, this._toPurl(entry.name, entry.version))
}
}

return sbom.getAsJsonString(opts)
}
Expand Down
Loading
Loading