diff --git a/README.md b/README.md index b74c62a3..a01da90b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,21 @@ let imageAnalysisWithArch = await client.imageAnalysis(['httpd:2.4.49^^amd64']) ``` + +

License Detection

+

+The client automatically detects your project's license with intelligent fallback: +

+ +

+See License Resolution and Compliance for detailed documentation. +

+ +

License Detection

+

+The client automatically detects your project's license with intelligent fallback: +

+ +

+See License Resolution and Compliance for detailed documentation. +

+ +

Excluding Packages

Excluding a package from any analysis can be achieved by marking the package for exclusion. diff --git a/docs/license-resolution-and-compliance.md b/docs/license-resolution-and-compliance.md index bfd88acc..479f018c 100644 --- a/docs/license-resolution-and-compliance.md +++ b/docs/license-resolution-and-compliance.md @@ -15,16 +15,23 @@ License analysis is **enabled by default** and provides: ### Project License Detection -The client looks for your project’s license in two places: +The client looks for your project’s license with **automatic fallback**: -1. **Manifest file** — Reads the license field from: +1. **Primary: Manifest file** — Reads the license field from: - `package.json`: `license` field - `pom.xml`: `` element - - Other ecosystems: varies by ecosystem (some don’t have standard license fields) + - `build.gradle` / `build.gradle.kts`: No standard license field (falls back to LICENSE file) + - `go.mod`: No standard license field (falls back to LICENSE file) + - `requirements.txt`: No standard license field (falls back to LICENSE file) -2. **LICENSE file** — Searches for `LICENSE`, `LICENSE.md`, or `LICENSE.txt` in the same directory as your manifest +2. **Fallback: LICENSE file** — If no license is found in the manifest, searches for `LICENSE`, `LICENSE.md`, or `LICENSE.txt` in the same directory as your manifest -The backend’s license identification API is used for accurate LICENSE file detection. +**How the fallback works:** +- **Ecosystems with manifest license support** (Maven, JavaScript): Uses manifest license if present, otherwise falls back to LICENSE file +- **Ecosystems without manifest license support** (Gradle, Go, Python): Automatically reads from LICENSE file +- **SPDX detection**: Common licenses (Apache-2.0, MIT, GPL-2.0/3.0, LGPL-2.1/3.0, AGPL-3.0, BSD-2-Clause/3-Clause) are automatically detected from LICENSE file content + +The backend’s license identification API is used for accurate LICENSE file detection when available. ### Compatibility Checking @@ -156,3 +163,9 @@ If you have a permissive-licensed project (MIT, Apache) but depend on GPL-licens ## SBOM Integration Project license information is automatically included in generated SBOMs (CycloneDX format) in the root component’s `licenses` field. + +**LICENSE file fallback in SBOMs:** +- **All ecosystems** now include license information in the SBOM when available +- **Gradle, Go, Python projects**: Even though these ecosystems don’t have manifest license fields, the SBOM will include the license from your LICENSE file +- **Maven, JavaScript projects**: The SBOM uses the manifest license, or falls back to LICENSE file if not specified in manifest +- If neither manifest nor LICENSE file contains a license, the SBOM root component will have no `licenses` field diff --git a/package.json b/package.json index 13e80b15..fcf2ed35 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "tests": "mocha --config .mocharc.json --grep \".*analysis module.*\" --invert", "tests:rep": "mocha --reporter-option maxDiffSize=0 --reporter json > unit-tests-result.json", "precompile": "rm -rf dist", - "compile": "tsc -p tsconfig.json" + "compile": "tsc -p tsconfig.json", + "compile:dev": "tsc -p tsconfig.dev.json" }, "dependencies": { "@babel/core": "^7.23.2", diff --git a/src/index.js b/src/index.js index 567a475b..436dcc88 100644 --- a/src/index.js +++ b/src/index.js @@ -9,7 +9,7 @@ import * as url from 'url'; export { parseImageRef } from "./oci_image/utils.js"; export { ImageRef } from "./oci_image/images.js"; -export { getProjectLicense, findLicenseFilePath, identifyLicenseViaBackend, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js"; +export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js"; export default { componentAnalysis, stackAnalysis, imageAnalysis, validateToken } diff --git a/src/license/compatibility.js b/src/license/compatibility.js deleted file mode 100644 index d8466b6f..00000000 --- a/src/license/compatibility.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * License compatibility: whether a dependency license is compatible with the project license. - * Relies on backend-provided license categories. - * - * Compatibility is based on restrictiveness hierarchy: - * PERMISSIVE < WEAK_COPYLEFT < STRONG_COPYLEFT - * - * A dependency is compatible if it's equal or less restrictive than the project license. - * A dependency is incompatible if it's more restrictive than the project license. - */ - -/** - * Check if a dependency's license is compatible with the project license based on backend categories. - * - * @param {string} [projectCategory] - backend category for project license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN - * @param {string} [dependencyCategory] - backend category for dependency license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN - * @returns {'compatible'|'incompatible'|'unknown'} - */ -export function getCompatibility(projectCategory, dependencyCategory) { - if (!projectCategory || !dependencyCategory) { - return 'unknown'; - } - - const proj = projectCategory.toUpperCase(); - const dep = dependencyCategory.toUpperCase(); - - // Unknown categories - if (proj === 'UNKNOWN' || dep === 'UNKNOWN') { - return 'unknown'; - } - - // Define restrictiveness levels (higher number = more restrictive) - const restrictiveness = { - 'PERMISSIVE': 1, - 'WEAK_COPYLEFT': 2, - 'STRONG_COPYLEFT': 3 - }; - - const projLevel = restrictiveness[proj]; - const depLevel = restrictiveness[dep]; - - if (projLevel === undefined || depLevel === undefined) { - return 'unknown'; - } - - // Dependency is more restrictive than project → incompatible - if (depLevel > projLevel) { - return 'incompatible'; - } - - // Dependency is equal or less restrictive → compatible - return 'compatible'; -} diff --git a/src/license/index.js b/src/license/index.js index de008967..112ae842 100644 --- a/src/license/index.js +++ b/src/license/index.js @@ -4,11 +4,11 @@ import { getProjectLicense, findLicenseFilePath, identifyLicense } from './project_license.js'; import { licensesFromReport, getLicenseDetails } from './licenses_api.js'; -import { getCompatibility } from './compatibility.js'; +import { getCompatibility } from './license_utils.js'; -export { getProjectLicense, findLicenseFilePath, identifyLicense as identifyLicenseViaBackend } from './project_license.js'; +export { getProjectLicense, findLicenseFilePath, identifyLicense } from './project_license.js'; export { licensesFromReport, normalizeLicensesResponse, getLicenseDetails } from './licenses_api.js'; -export { getCompatibility } from './compatibility.js'; +export { getCompatibility } from './license_utils.js'; /** * Run full license check: resolve project license (with backend identification and details), @@ -23,7 +23,7 @@ export { getCompatibility } from './compatibility.js'; */ export async function runLicenseCheck(sbomContent, manifestPath, url, opts = {}, analysisResult = null) { // Resolve project license from manifest and LICENSE file - const projectLicense = getProjectLicense(manifestPath, opts); + const projectLicense = getProjectLicense(manifestPath); // Try backend identification for LICENSE file (more accurate than local pattern matching) const licenseFilePath = findLicenseFilePath(manifestPath); diff --git a/src/license/license_utils.js b/src/license/license_utils.js new file mode 100644 index 00000000..4e845a00 --- /dev/null +++ b/src/license/license_utils.js @@ -0,0 +1,127 @@ +/** + * License utilities: file reading, SPDX detection, normalization, compatibility. + * This module has NO dependencies on providers or backend to avoid circular dependencies. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; + +/** + * Find LICENSE file path in the same directory as the manifest. + * @param {string} manifestPath + * @returns {string|null} - path to LICENSE file or null if not found + */ +export function findLicenseFilePath(manifestPath) { + const manifestDir = path.dirname(path.resolve(manifestPath)); + + for (const name of LICENSE_FILES) { + const filePath = path.join(manifestDir, name); + try { + if (fs.statSync(filePath).isFile()) { + return filePath; + } + } catch { + // skip + } + } + return null; +} + +/** + * Very simple SPDX detection from common license text (first ~500 chars). + * @param {string} text + * @returns {string|null} + */ +export function detectSpdxFromText(text) { + const head = text.slice(0, 500); + if (/Apache License,?\s*Version 2\.0/i.test(head)) { return 'Apache-2.0'; } + if (/MIT License/i.test(head) && /Permission is hereby granted/i.test(head)) { return 'MIT'; } + if (/GNU AFFERO GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) { return 'AGPL-3.0-only'; } + if (/GNU LESSER GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) { return 'LGPL-3.0-only'; } + if (/GNU LESSER GENERAL PUBLIC LICENSE\s+Version 2\.1/i.test(head)) { return 'LGPL-2.1-only'; } + if (/GNU GENERAL PUBLIC LICENSE\s+Version 2/i.test(head)) { return 'GPL-2.0-only'; } + if (/GNU GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) { return 'GPL-3.0-only'; } + if (/BSD 2-Clause/i.test(head)) { return 'BSD-2-Clause'; } + if (/BSD 3-Clause/i.test(head)) { return 'BSD-3-Clause'; } + return null; +} + +/** + * Read LICENSE file and detect SPDX identifier. + * @param {string} manifestPath - path to manifest + * @returns {string|null} - SPDX identifier from LICENSE file or null + */ +export function readLicenseFile(manifestPath) { + const licenseFilePath = findLicenseFilePath(manifestPath); + if (!licenseFilePath) { return null; } + + try { + const content = fs.readFileSync(licenseFilePath, 'utf-8'); + return detectSpdxFromText(content) || content.split('\n')[0]?.trim() || null; + } catch { + return null; + } +} + +/** + * Get project license from manifest or LICENSE file. + * Returns manifestLicense if provided, otherwise tries LICENSE file. + * @param {string|null} manifestLicense - license from manifest (or null) + * @param {string} manifestPath - path to manifest + * @returns {string|null} - SPDX identifier or null + */ +export function getLicense(manifestLicense, manifestPath) { + return manifestLicense || readLicenseFile(manifestPath) || null; +} + +/** + * Normalize SPDX identifier for comparison (lowercase, strip common suffixes). + * @param {string} spdxOrName + * @returns {string} + */ +export function normalizeSpdx(spdxOrName) { + const s = String(spdxOrName).trim().toLowerCase(); + if (s.endsWith(' license')) { return s.slice(0, -8); } + return s; +} + +/** + * Check if a dependency's license is compatible with the project license based on backend categories. + * + * @param {string} [projectCategory] - backend category for project license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN + * @param {string} [dependencyCategory] - backend category for dependency license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN + * @returns {'compatible'|'incompatible'|'unknown'} + */ +export function getCompatibility(projectCategory, dependencyCategory) { + if (!projectCategory || !dependencyCategory) { + return 'unknown'; + } + + const proj = projectCategory.toUpperCase(); + const dep = dependencyCategory.toUpperCase(); + + if (proj === 'UNKNOWN' || dep === 'UNKNOWN') { + return 'unknown'; + } + + const restrictiveness = { + 'PERMISSIVE': 1, + 'WEAK_COPYLEFT': 2, + 'STRONG_COPYLEFT': 3 + }; + + const projLevel = restrictiveness[proj]; + const depLevel = restrictiveness[dep]; + + if (projLevel === undefined || depLevel === undefined) { + return 'unknown'; + } + + if (depLevel > projLevel) { + return 'incompatible'; + } + + return 'compatible'; +} diff --git a/src/license/project_license.js b/src/license/project_license.js index ce4b7095..aec3f64f 100644 --- a/src/license/project_license.js +++ b/src/license/project_license.js @@ -10,7 +10,10 @@ import { selectTrustifyDABackend } from '../index.js'; import { matchForLicense, availableProviders } from '../provider.js'; import { addProxyAgent, getTokenHeaders } from '../tools.js'; -const LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; +import { + normalizeSpdx, + readLicenseFile +} from './license_utils.js'; /** * Resolve project license from manifest and from LICENSE / LICENSE.md in manifest dir or git root. @@ -23,7 +26,7 @@ export function getProjectLicense(manifestPath) { const resolved = path.resolve(manifestPath); const provider = matchForLicense(resolved, availableProviders); const fromManifest = provider.readLicenseFromManifest(resolved); - const fromFile = readLicenseFromFile(resolved); + const fromFile = readLicenseFile(resolved); const mismatch = Boolean( fromManifest && fromFile && normalizeSpdx(fromManifest) !== normalizeSpdx(fromFile) ); @@ -34,26 +37,7 @@ export function getProjectLicense(manifestPath) { }; } -/** - * Find LICENSE file path in the same directory as the manifest. - * @param {string} manifestPath - * @returns {string|null} - path to LICENSE file or null if not found - */ -export function findLicenseFilePath(manifestPath) { - const manifestDir = path.dirname(path.resolve(manifestPath)); - - for (const name of LICENSE_FILES) { - const filePath = path.join(manifestDir, name); - try { - if (fs.statSync(filePath).isFile()) { - return filePath; - } - } catch { - // skip - } - } - return null; -} +export { findLicenseFilePath, readLicenseFile } from './license_utils.js'; /** * Call backend /licenses/identify endpoint to identify license from file. @@ -65,7 +49,7 @@ export async function identifyLicense(licenseFilePath, opts = {}) { try { const fileContent = fs.readFileSync(licenseFilePath); const backendUrl = selectTrustifyDABackend(opts); - const url = new URL(`${backendUrl}/licenses/identify`); + const url = new URL(`${backendUrl}/api/v5/licenses/identify`); const tokenHeaders = getTokenHeaders(opts); const fetchOptions = addProxyAgent({ method: 'POST', @@ -90,46 +74,13 @@ export async function identifyLicense(licenseFilePath, opts = {}) { } /** - * Find and read LICENSE or LICENSE.md; use local pattern matching for identification. - * @param {string} manifestPath - * @returns {string|null} - */ -function readLicenseFromFile(manifestPath) { - const licenseFilePath = findLicenseFilePath(manifestPath); - if (!licenseFilePath) {return null;} - - try { - const content = fs.readFileSync(licenseFilePath, 'utf-8'); - return detectSpdxFromText(content) || content.split('\n')[0]?.trim() || null; - } catch { - return null; - } -} - -/** - * Very simple SPDX detection from common license text (first ~500 chars). - * @param {string} text - * @returns {string|null} - */ -function detectSpdxFromText(text) { - const head = text.slice(0, 500); - if (/Apache License,?\s*Version 2\.0/i.test(head)) {return 'Apache-2.0';} - if (/MIT License/i.test(head) && /Permission is hereby granted/i.test(head)) {return 'MIT';} - if (/GNU GENERAL PUBLIC LICENSE\s+Version 2/i.test(head)) {return 'GPL-2.0-only';} - if (/GNU GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) {return 'GPL-3.0-only';} - if (/BSD 2-Clause/i.test(head)) {return 'BSD-2-Clause';} - if (/BSD 3-Clause/i.test(head)) {return 'BSD-3-Clause';} - return null; -} - -/** - * Normalize for comparison (lowercase, strip common suffixes). - * @param {string} spdxOrName - * @returns {string} + * Get license for SBOM root component with fallback to LICENSE file. + * Priority: manifest license > LICENSE file > null + * This is used by providers to populate the license field in the SBOM. + * @param {string} manifestPath - path to manifest + * @returns {string|null} - SPDX identifier or null */ -function normalizeSpdx(spdxOrName) { - const s = String(spdxOrName).trim().toLowerCase(); - // e.g. "MIT" vs "MIT License" - if (s.endsWith(' license')) {return s.slice(0, -8);} - return s; +export function getLicenseForSbom(manifestPath) { + const { fromManifest, fromFile } = getProjectLicense(manifestPath); + return fromManifest || fromFile || null; } diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index 236ca4c7..2d863539 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -2,6 +2,7 @@ import fs from 'node:fs' import os from "node:os"; import path from 'node:path' +import { getLicense } from '../license/license_utils.js' import Sbom from '../sbom.js' import { getCustom, getCustomPath, invokeCommand, toPurl, toPurlFromString } from "../tools.js"; @@ -156,20 +157,20 @@ export default class Base_javascript { * @returns {string|null} */ readLicenseFromManifest(manifestPath) { + let manifestLicense; try { const content = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); if (typeof content.license === 'string') { - return content.license.trim() || null; - } - if (Array.isArray(content.licenses) && content.licenses.length > 0) { + manifestLicense = content.license.trim() || null; + } else if (Array.isArray(content.licenses) && content.licenses.length > 0) { const first = content.licenses[0]; const name = first.type || first.name; - return typeof name === 'string' ? name.trim() : null; + manifestLicense = (typeof name === 'string' ? name.trim() : null); } - return null; } catch { - return null; + manifestLicense = null; } + return getLicense(manifestLicense, manifestPath); } /** diff --git a/src/providers/golang_gomodules.js b/src/providers/golang_gomodules.js index 8b5efff5..f741e8d3 100644 --- a/src/providers/golang_gomodules.js +++ b/src/providers/golang_gomodules.js @@ -4,6 +4,7 @@ import { EOL } from "os"; import { PackageURL } from 'packageurl-js' +import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' import { getCustom, getCustomPath, invokeCommand } from "../tools.js"; @@ -40,7 +41,7 @@ function isSupported(manifestName) { * @returns {string|null} */ // eslint-disable-next-line no-unused-vars -function readLicenseFromManifest(manifestPath) { return null } +function readLicenseFromManifest(manifestPath) { return readLicenseFile(manifestPath); } /** * @param {string} manifestDir - the directory where the manifest lies diff --git a/src/providers/java_gradle.js b/src/providers/java_gradle.js index 6481a154..f7095dac 100644 --- a/src/providers/java_gradle.js +++ b/src/providers/java_gradle.js @@ -4,6 +4,7 @@ import { EOL } from 'os' import TOML from 'fast-toml' +import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' import Base_java, { ecosystem_gradle } from "./base_java.js"; @@ -75,7 +76,7 @@ export default class Java_gradle extends Base_java { * @returns {null} */ // eslint-disable-next-line no-unused-vars - readLicenseFromManifest(manifestPath) { return null; } + readLicenseFromManifest(manifestPath) { return readLicenseFile(manifestPath); } /** * Provide content and content type for stack analysis. diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index f87127e1..0e827172 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -5,6 +5,7 @@ import { EOL } from 'os' import { XMLParser } from 'fast-xml-parser' +import { getLicense } from '../license/license_utils.js' import Sbom from '../sbom.js' import { getCustom } from '../tools.js' @@ -66,27 +67,27 @@ export default class Java_maven extends Base_java { } /** - * Read license from pom.xml manifest + * Read license from pom.xml manifest, with fallback to LICENSE file * @param {string} manifestPath - path to pom.xml * @returns {string|null} */ readLicenseFromManifest(manifestPath) { + let fromPom = null; try { const xml = fs.readFileSync(manifestPath, 'utf-8'); const parser = new XMLParser({ ignoreAttributes: false }); const obj = parser.parse(xml); const project = obj?.project; - if (!project?.licenses?.license) { - return null; + if (project?.licenses?.license) { + const license = Array.isArray(project.licenses.license) + ? project.licenses.license[0] + : project.licenses.license; + fromPom = (license?.name && license.name.trim()) || null; } - const license = Array.isArray(project.licenses.license) - ? project.licenses.license[0] - : project.licenses.license; - const name = (license?.name && license.name.trim()) || null; - return name || null; } catch { - return null; + // leave fromPom as null } + return getLicense(fromPom, manifestPath); } /** diff --git a/src/providers/python_pip.js b/src/providers/python_pip.js index 3fea30e1..139b4544 100644 --- a/src/providers/python_pip.js +++ b/src/providers/python_pip.js @@ -2,6 +2,7 @@ import fs from 'node:fs' import { PackageURL } from 'packageurl-js' +import { readLicenseFile } from '../license/license_utils.js' import Sbom from '../sbom.js' import { environmentVariableIsPopulated, @@ -37,7 +38,7 @@ function isSupported(manifestName) { * @returns {string|null} */ // eslint-disable-next-line no-unused-vars -function readLicenseFromManifest(manifestPath) { return null } +function readLicenseFromManifest(manifestPath) { return readLicenseFile(manifestPath); } /** * @param {string} manifestDir - the directory where the manifest lies diff --git a/test/providers/license_fallback.test.js b/test/providers/license_fallback.test.js new file mode 100644 index 00000000..9d4060a0 --- /dev/null +++ b/test/providers/license_fallback.test.js @@ -0,0 +1,70 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { expect } from 'chai'; + +import golangGomodulesProvider from '../../src/providers/golang_gomodules.js'; +import Java_gradle_groovy from '../../src/providers/java_gradle_groovy.js'; +import pythonPipProvider from '../../src/providers/python_pip.js'; + +// Test LICENSE file fallback feature +suite('LICENSE file fallback for providers without manifest license support', () => { + let tempDir; + + setup(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'license-test-')); + }); + + teardown(() => { + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('Gradle provider should read LICENSE file when present', () => { + const buildGradle = path.join(tempDir, 'build.gradle'); + const licenseFile = path.join(tempDir, 'LICENSE'); + + fs.writeFileSync(buildGradle, 'plugins { id "java" }'); + fs.writeFileSync(licenseFile, 'Apache License, Version 2.0'); + + const provider = new Java_gradle_groovy(); + const license = provider.readLicenseFromManifest(buildGradle); + + expect(license).to.equal('Apache-2.0'); + }); + + test('Golang provider should read LICENSE file when present', () => { + const goMod = path.join(tempDir, 'go.mod'); + const licenseFile = path.join(tempDir, 'LICENSE'); + + fs.writeFileSync(goMod, 'module example.com/test'); + fs.writeFileSync(licenseFile, 'MIT License\n\nPermission is hereby granted'); + + const license = golangGomodulesProvider.readLicenseFromManifest(goMod); + + expect(license).to.equal('MIT'); + }); + + test('Python provider should read LICENSE file when present', () => { + const requirements = path.join(tempDir, 'requirements.txt'); + const licenseFile = path.join(tempDir, 'LICENSE'); + + fs.writeFileSync(requirements, 'requests==2.28.0'); + fs.writeFileSync(licenseFile, 'BSD 3-Clause License'); + + const license = pythonPipProvider.readLicenseFromManifest(requirements); + + expect(license).to.equal('BSD-3-Clause'); + }); + + test('Providers should return null when no LICENSE file exists', () => { + const goMod = path.join(tempDir, 'go.mod'); + fs.writeFileSync(goMod, 'module example.com/test'); + + const license = golangGomodulesProvider.readLicenseFromManifest(goMod); + + expect(license).to.be.null; + }); +}); diff --git a/tsconfig.dev.json b/tsconfig.dev.json new file mode 100644 index 00000000..738679d7 --- /dev/null +++ b/tsconfig.dev.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true + } +}