Skip to content
Open
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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a side effect of using pip install --dry-run . --report

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a cleanup for newly created .egg-info folders so that they're not present after the user runs the analysis if they weren't already there.

98 changes: 98 additions & 0 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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.

## 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
156 changes: 156 additions & 0 deletions src/providers/python_pip_pyproject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import fs from 'node:fs'
import path from 'node:path'

import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'

import Base_pyproject from './base_pyproject.js'

/**
* Python provider for pyproject.toml files using PEP 621 format without a lock file.
* Uses `pip install --dry-run --ignore-installed --report` to resolve the full dependency tree.
* Acts as the fallback provider when no lock file (uv.lock/poetry.lock) is found.
*/
export default class Python_pip_pyproject extends Base_pyproject {

/** @returns {string} */
_lockFileName() {
return '.pip-lock-nonexistent'
}

/** @returns {string} */
_cmdName() {
return 'pip'
}

/**
* Always returns true — pip provider is the fallback when no lock file is found.
* @param {string} manifestDir
* @param {{}} [opts={}]
* @returns {boolean}
*/
// eslint-disable-next-line no-unused-vars
validateLockFile(manifestDir, opts = {}) {
return true
}

/**
* Get pip report output from env var override or by running pip.
* @param {string} manifestDir - directory containing pyproject.toml
* @param {{}} [opts={}]
* @returns {string} pip report JSON string
*/
_getPipReportOutput(manifestDir, opts) {
if (environmentVariableIsPopulated('TRUSTIFY_DA_PIP_REPORT')) {
return Buffer.from(process.env['TRUSTIFY_DA_PIP_REPORT'], 'base64').toString('ascii')
}
let pipBin = getCustomPath('pip3', opts)
try {
invokeCommand(pipBin, ['--version'])
} catch {
pipBin = getCustomPath('pip', opts)
}
let eggInfoDirs = this._findEggInfoDirs(manifestDir)
let result = invokeCommand(pipBin, [
'install', '--dry-run', '--ignore-installed', '--quiet', '--report', '-', '.'
], { cwd: manifestDir }).toString()
this._cleanupEggInfo(manifestDir, eggInfoDirs)
return result
}

/**
* Parse pip report JSON and build dependency graph.
* @param {string} reportJson - pip report JSON string
* @returns {{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}}
*/
_parsePipReport(reportJson) {
let report = JSON.parse(reportJson)
let packages = report.install || []

let rootEntry = packages.find(p => p.download_info?.dir_info !== undefined)
let rootRequires = rootEntry?.metadata?.requires_dist || []

let directDepNames = new Set()
for (let req of rootRequires) {
if (this._hasExtraMarker(req)) { continue }
let name = this._extractDepName(req)
if (name) { directDepNames.add(this._canonicalize(name)) }
}

let graph = new Map()
let nonRootPackages = packages.filter(p => p !== rootEntry)

for (let pkg of nonRootPackages) {
let name = pkg.metadata.name
let version = pkg.metadata.version
let key = this._canonicalize(name)
graph.set(key, { name, version, children: [] })
}

for (let pkg of nonRootPackages) {
let key = this._canonicalize(pkg.metadata.name)
let entry = graph.get(key)
let requires = pkg.metadata.requires_dist || []
for (let req of requires) {
if (this._hasExtraMarker(req)) { continue }
let depName = this._extractDepName(req)
if (!depName) { continue }
let depKey = this._canonicalize(depName)
if (graph.has(depKey)) {
entry.children.push(depKey)
}
}
}

let directDeps = [...directDepNames].filter(key => graph.has(key))
return { directDeps, graph }
}

/**
* Check if a requires_dist entry is an extras-only dependency.
* @param {string} req - e.g. "PySocks!=1.5.7,>=1.5.6; extra == \"socks\""
* @returns {boolean}
*/
_hasExtraMarker(req) {
return /;\s*.*extra\s*==/.test(req)
}

/**
* Extract package name from a requires_dist entry.
* @param {string} req - e.g. "charset_normalizer<4,>=2"
* @returns {string|null}
*/
_extractDepName(req) {
let match = req.match(/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)/)
return match ? match[1] : null
}

/**
* Resolve dependencies using pip install --dry-run --report.
* @param {string} manifestDir
* @param {string} _workspaceDir - unused (pip resolves from manifest directory)
* @param {object} parsed - parsed pyproject.toml
* @param {{}} [opts={}]
* @returns {Promise<{directDeps: string[], graph: Map}>}
*/
// eslint-disable-next-line no-unused-vars
async _getDependencyData(manifestDir, _workspaceDir, parsed, opts) {
let reportOutput = this._getPipReportOutput(manifestDir, opts)
return this._parsePipReport(reportOutput)
}

_findEggInfoDirs(dir) {
try {
return fs.readdirSync(dir).filter(f => f.endsWith('.egg-info'))
} catch {
return []
}
}

_cleanupEggInfo(dir, existing) {
for (let entry of this._findEggInfoDirs(dir)) {
if (!existing.includes(entry)) {
fs.rmSync(path.join(dir, entry), { recursive: true, force: true })
}
}
}
}
Loading
Loading