diff --git a/.github/workflows/Security-Reachability.yml b/.github/workflows/Security-Reachability.yml index 442ebdd..511e43a 100644 --- a/.github/workflows/Security-Reachability.yml +++ b/.github/workflows/Security-Reachability.yml @@ -80,7 +80,7 @@ jobs: # validate:package:skip-reachability command so workflow validation does not modify its checkout. - name: Install Socket CLI background: true - run: sfw pip install socketsecurity==2.5.7 uv --upgrade + run: sfw pip install socketsecurity==2.7.2 uv --upgrade # Bring job back to sync execution by awaiting for all async jobs to finish before continuing - name: Steps - Convert Back To Synchronous Execution - Packages Updates/Setup diff --git a/.github/workflows/Test-Unit.yml b/.github/workflows/Test-Unit.yml index b719882..3208335 100644 --- a/.github/workflows/Test-Unit.yml +++ b/.github/workflows/Test-Unit.yml @@ -47,7 +47,7 @@ jobs: # Promote the coverage summary into the GitHub Actions job summary for easier PR review - name: Publish Coverage Summary - run: node ./scripts/publish-coverage-summary.mjs + run: node ./scripts/publish-coverage-summary.ts # Create or update one coverage comment for pull request runs. - name: Publish Coverage Report to Pull Request @@ -58,7 +58,7 @@ jobs: with: script: | const { pathToFileURL } = require('node:url'); - const commentScriptUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/publish-coverage-comment.mjs`); + const commentScriptUrl = pathToFileURL(`${process.env.GITHUB_WORKSPACE}/scripts/publish-coverage-comment.ts`); const { default: publishCoverageComment } = await import(commentScriptUrl.href); await publishCoverageComment({ github, diff --git a/.npmignore b/.npmignore index 83f72b6..dc2425b 100644 --- a/.npmignore +++ b/.npmignore @@ -18,6 +18,7 @@ eslint.config.mjs vitest.config.ts package-lock.json .npmrc +.gitattributes # Dependencies and logs /node_modules diff --git a/package.json b/package.json index 56ace02..8fb0989 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,25 @@ "type": "git", "url": "git+https://github.com/Software-Hardware-Integration-Lab/LogEngine.git" }, + "exports": { + ".": { + "types": "./bin/index.d.ts", + "import": "./bin/index.js" + } + }, "type": "module", "scripts": { "build": "npm run clean:bin && tspc -p tsconfig.json", "build:coverage": "npm run clean:bin && tspc -p coverage.tsconfig.json", "build:prod": "npm run clean:bin && tspc -p prod.tsconfig.json", - "clean:bin": "node ./scripts/clean-bin.mjs", + "clean:bin": "node ./scripts/clean-bin.ts", "lint": "eslint", "test": "npm run build:coverage && vitest run", "test:watch": "vitest", "coverage": "npm run build:coverage && vitest run --coverage", "validate:package": "npm run update:reachability-pin && npm run validate:package:skip-reachability", - "validate:package:skip-reachability": "npm run lint && npm run coverage && npm run build:prod && node ./scripts/validate-package.mjs", - "update:reachability-pin": "node ./scripts/update-reachability-pin.mjs", + "validate:package:skip-reachability": "npm run lint && npm run coverage && npm run build:prod && node ./scripts/validate-package.ts", + "update:reachability-pin": "node ./scripts/update-reachability-pin.ts", "prepack": "npm run validate:package", "postinstall": "ts-patch install -s" }, diff --git a/scripts/clean-bin.mjs b/scripts/clean-bin.ts similarity index 100% rename from scripts/clean-bin.mjs rename to scripts/clean-bin.ts diff --git a/scripts/coverage-thresholds.d.ts b/scripts/coverage-thresholds.d.ts deleted file mode 100644 index 8321c09..0000000 --- a/scripts/coverage-thresholds.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Specifies the minimum acceptable coverage percentages. */ -export declare const coverageThresholds: { - branches: number; - functions: number; - lines: number; - statements: number; -}; diff --git a/scripts/coverage-thresholds.js b/scripts/coverage-thresholds.ts similarity index 100% rename from scripts/coverage-thresholds.js rename to scripts/coverage-thresholds.ts diff --git a/scripts/publish-coverage-comment.mjs b/scripts/publish-coverage-comment.mjs deleted file mode 100644 index 1ab2d6d..0000000 --- a/scripts/publish-coverage-comment.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import { readFile } from 'node:fs/promises'; -/** Identifies coverage comments managed by this script. */ -const marker = ''; -/** - * Publishes or updates the coverage summary comment on the current issue. - * @param options Provides the GitHub Script inputs. - */ -export default async function publishCoverageComment(options) { - /** Gets the GitHub Script inputs. */ - const { github, context, summaryPath } = options; - /** Gets the Markdown coverage summary. */ - const coverageSummary = (await readFile(summaryPath, 'utf8')).trim(); - /** Gets the managed coverage comment body. */ - const body = `${marker}\n${coverageSummary}`; - /** Gets the target repository owner and name. */ - const { owner, repo } = context.repo; - /** Gets the issue number for the coverage comment. */ - const issueNumber = context.issue.number; - /** Gets all existing comments on the current issue. */ - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - 'issue_number': issueNumber, - 'per_page': 100 - }); - /** Gets the existing managed coverage comment, when present. */ - const existingComment = comments.find((comment) => comment.body?.includes(marker) ?? false); - if (existingComment) { - await github.rest.issues.updateComment({ - owner, - repo, - 'comment_id': existingComment.id, - body - }); - } - else { - await github.rest.issues.createComment({ - owner, - repo, - 'issue_number': issueNumber, - body - }); - } -} diff --git a/scripts/publish-coverage-comment.ts b/scripts/publish-coverage-comment.ts new file mode 100644 index 0000000..26e3fc9 --- /dev/null +++ b/scripts/publish-coverage-comment.ts @@ -0,0 +1,121 @@ +import { readFile } from 'node:fs/promises'; + +/** Represents a GitHub issue comment. */ +interface IssueComment { + /** Gets the comment body. */ + 'body'?: string | null; + /** Gets the comment identifier. */ + 'id': number; +} + +/** Defines the GitHub Script inputs needed to publish a coverage comment. */ +interface PublishCoverageCommentOptions { + /** Provides GitHub Actions workflow context. */ + 'context': { + /** Provides pull request or issue details. */ + 'issue': { + /** Gets the issue number. */ + 'number': number; + }; + /** Provides the target repository details. */ + 'repo': { + /** Gets the repository owner. */ + 'owner': string; + /** Gets the repository name. */ + 'repo': string; + }; + }; + /** Provides the GitHub REST client. */ + 'github': { + /** Retrieves all pages for a REST endpoint. */ + 'paginate': ( + route: unknown, + parameters: { + /** Specifies the issue number. */ + 'issue_number': number; + /** Specifies the repository owner. */ + 'owner': string; + /** Specifies the number of results per page. */ + 'per_page': number; + /** Specifies the repository name. */ + 'repo': string; + } + ) => Promise; + /** Exposes GitHub REST endpoint groups. */ + 'rest': { + /** Exposes issue and issue-comment endpoints. */ + 'issues': { + /** Creates an issue comment. */ + 'createComment': (parameters: { + /** Specifies the comment body. */ + 'body': string; + /** Specifies the issue number. */ + 'issue_number': number; + /** Specifies the repository owner. */ + 'owner': string; + /** Specifies the repository name. */ + 'repo': string; + }) => Promise; + /** Lists issue comments. */ + 'listComments': unknown; + /** Updates an issue comment. */ + 'updateComment': (parameters: { + /** Specifies the comment body. */ + 'body': string; + /** Specifies the comment identifier. */ + 'comment_id': number; + /** Specifies the repository owner. */ + 'owner': string; + /** Specifies the repository name. */ + 'repo': string; + }) => Promise; + }; + }; + }; + /** Specifies the path to the generated coverage summary. */ + 'summaryPath': string; +} + +/** Identifies coverage comments managed by this script. */ +const marker = ''; +/** + * Publishes or updates the coverage summary comment on the current issue. + * @param options Provides the GitHub Script inputs. + */ +export default async function publishCoverageComment(options: PublishCoverageCommentOptions) { + /** Gets the GitHub Script inputs. */ + const { github, context, summaryPath } = options; + /** Gets the Markdown coverage summary. */ + const coverageSummary = (await readFile(summaryPath, 'utf8')).trim(); + /** Gets the managed coverage comment body. */ + const body = `${ marker }\n${ coverageSummary }`; + /** Gets the target repository owner and name. */ + const { owner, repo } = context.repo; + /** Gets the issue number for the coverage comment. */ + const issueNumber = context.issue.number; + /** Gets all existing comments on the current issue. */ + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + 'issue_number': issueNumber, + 'per_page': 100 + }); + /** Gets the existing managed coverage comment, when present. */ + const existingComment = comments.find((comment) => comment.body?.includes(marker) ?? false); + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + 'comment_id': existingComment.id, + body + }); + } + else { + await github.rest.issues.createComment({ + owner, + repo, + 'issue_number': issueNumber, + body + }); + } +} diff --git a/scripts/publish-coverage-summary.mjs b/scripts/publish-coverage-summary.ts similarity index 76% rename from scripts/publish-coverage-summary.mjs rename to scripts/publish-coverage-summary.ts index dc73c8f..ae2b709 100644 --- a/scripts/publish-coverage-summary.mjs +++ b/scripts/publish-coverage-summary.ts @@ -1,5 +1,5 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'; -import { coverageThresholds } from './coverage-thresholds.js'; +import { coverageThresholds } from './coverage-thresholds.ts'; /** Specifies the source coverage summary path. */ const coverageSummaryPath = 'coverage/coverage-summary.json'; /** Specifies the generated Markdown summary path. */ @@ -7,13 +7,15 @@ const summaryOutputPath = 'coverage-summary.md'; /** Gets the aggregate coverage metrics from the summary. */ const { total } = JSON.parse(await readFile(coverageSummaryPath, 'utf8')); /** Lists the coverage metrics to include in the report. */ -const metrics = ['statements', 'branches', 'functions', 'lines']; +const metrics: (keyof typeof coverageThresholds)[] = [ + 'statements', 'branches', 'functions', 'lines' +]; /** Formats the coverage metrics for the Markdown report. */ const coverageMetrics = metrics.map((name) => { /** Gets the coverage data for the current metric. */ const metric = total[name]; if (!metric) { - throw new Error(`Missing ${name} coverage metric.`); + throw new Error(`Missing ${ name } coverage metric.`); } /** Returns a report row for the current coverage metric. */ return { @@ -28,11 +30,11 @@ const coverageMetrics = metrics.map((name) => { const meetsThresholds = coverageMetrics.every((metric) => metric.percentage >= metric.threshold); /** Gets the Markdown coverage report. */ const summary = [ - `## ${meetsThresholds ? '🟢' : '😡'} Coverage Report`, + `## ${ meetsThresholds ? '🟢' : '😡' } Coverage Report`, '', '| Metric | Coverage | Threshold | Covered |', '| --- | ---: | ---: | ---: |', - ...coverageMetrics.map((metric) => `| ${metric.name} | ${metric.percentage}% | ${metric.threshold}% | ${metric.covered}/${metric.total} |`), + ...coverageMetrics.map((metric) => `| ${ metric.name } | ${ metric.percentage }% | ${ metric.threshold }% | ${ metric.covered }/${ metric.total } |`), '' ].join('\n'); await writeFile(summaryOutputPath, summary); diff --git a/scripts/update-reachability-pin.mjs b/scripts/update-reachability-pin.ts similarity index 76% rename from scripts/update-reachability-pin.mjs rename to scripts/update-reachability-pin.ts index f9b2856..df762e6 100644 --- a/scripts/update-reachability-pin.mjs +++ b/scripts/update-reachability-pin.ts @@ -1,5 +1,20 @@ import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; + +/** Represents a release file returned by PyPI. */ +interface PyPiReleaseFile { + /** Gets the file upload time. */ + 'upload_time_iso_8601': string; + /** Indicates whether the file has been withdrawn. */ + 'yanked': boolean; +} + +/** Represents the portion of the PyPI response consumed by this script. */ +interface PyPiResponse { + /** Gets release files indexed by version. */ + 'releases': Record; +} + /** Specifies the PyPI endpoint for socketsecurity releases. */ const PYPI_URL = 'https://pypi.org/pypi/socketsecurity/json'; /** Specifies the minimum release age before a version is eligible. */ @@ -16,7 +31,7 @@ const pinPattern = /socketsecurity==(?\d+(?:\.\d+)*)/gu; * @param right Specifies the right version. * @returns A negative value, zero, or a positive value according to version ordering. */ -function compareVersions(left, right) { +function compareVersions(left: string, right: string): number { /** Gets the numeric components of the left version. */ const leftParts = left.split('.').map(Number); /** Gets the numeric components of the right version. */ @@ -35,23 +50,23 @@ function compareVersions(left, right) { /** Gets the PyPI response for socketsecurity releases. */ const response = await fetch(PYPI_URL); if (!response.ok) { - throw new Error(`Unable to retrieve socketsecurity releases from PyPI: ${response.status} ${response.statusText}`); + throw new Error(`Unable to retrieve socketsecurity releases from PyPI: ${ response.status } ${ response.statusText }`); } /** Gets release files indexed by socketsecurity version. */ -const { releases } = await response.json(); +const { releases } = await response.json() as PyPiResponse; /** Gets the latest timestamp at which a release is eligible. */ const cutoffTime = Date.now() - RELEASE_AGE_MILLISECONDS; /** Gets the newest non-yanked stable releases old enough to use. */ const eligibleReleases = Object.entries(releases) .filter(([version, files]) => versionPattern.test(version) && Array.isArray(files) && files.length > 0 && files.every((file) => !file.yanked)) .map(([version, files]) => { - /** Gets the most recent upload time for the release. */ - const mostRecentUpload = Math.max(...files.map((file) => Date.parse(file.upload_time_iso_8601))); - return { - mostRecentUpload, - version - }; -}) + /** Gets the most recent upload time for the release. */ + const mostRecentUpload = Math.max(...files.map((file) => Date.parse(file.upload_time_iso_8601))); + return { + mostRecentUpload, + version + }; + }) .filter(({ mostRecentUpload }) => Number.isFinite(mostRecentUpload) && mostRecentUpload <= cutoffTime) .sort((left, right) => compareVersions(right.version, left.version)); /** Gets the newest eligible release. */ @@ -64,19 +79,19 @@ const workflow = await readFile(workflowPath, 'utf8'); /** Gets all socketsecurity pins in the workflow. */ const pins = [...workflow.matchAll(pinPattern)]; if (pins.length !== 1) { - throw new Error(`Expected exactly one socketsecurity pin in ${workflowPath}, found ${pins.length}.`); + throw new Error(`Expected exactly one socketsecurity pin in ${ workflowPath }, found ${ pins.length }.`); } /** Gets the socketsecurity version captured from the workflow pin. */ const currentVersion = pins[0]?.groups?.['version']; if (!currentVersion) { - throw new Error(`Unable to extract the socketsecurity version from ${workflowPath}.`); + throw new Error(`Unable to extract the socketsecurity version from ${ workflowPath }.`); } if (currentVersion === latestEligibleRelease.version) { - process.stdout.write(`Reachability pin is current: socketsecurity==${currentVersion}\n`); + process.stdout.write(`Reachability pin is current: socketsecurity==${ currentVersion }\n`); } else { /** Gets the workflow content with the updated socketsecurity pin. */ - const updatedWorkflow = workflow.replace(pinPattern, `socketsecurity==${latestEligibleRelease.version}`); + const updatedWorkflow = workflow.replace(pinPattern, `socketsecurity==${ latestEligibleRelease.version }`); await writeFile(workflowPath, updatedWorkflow); - process.stdout.write(`Updated reachability pin: socketsecurity==${currentVersion} -> socketsecurity==${latestEligibleRelease.version}\n`); + process.stdout.write(`Updated reachability pin: socketsecurity==${ currentVersion } -> socketsecurity==${ latestEligibleRelease.version }\n`); } diff --git a/scripts/validate-package.mjs b/scripts/validate-package.ts similarity index 75% rename from scripts/validate-package.mjs rename to scripts/validate-package.ts index 579f665..ae5c539 100644 --- a/scripts/validate-package.mjs +++ b/scripts/validate-package.ts @@ -1,4 +1,17 @@ import { spawnSync } from 'node:child_process'; + +/** Represents the package metadata produced by npm pack. */ +interface PackageEntry { + /** Gets files included in the package tarball. */ + 'files'?: { + /** Gets the file path inside the tarball. */ + 'path': string; + }[]; +} + +/** Represents supported npm pack JSON response shapes. */ +type PackageMetadata = PackageEntry[] | Record; + /** Indicates whether the script is running on Windows. */ const isWindows = process.platform === 'win32'; /** Gets the executable used to invoke npm. */ @@ -20,7 +33,7 @@ if (packageResult.status !== 0) { process.exit(packageResult.status ?? 1); } /** Gets the parsed npm pack metadata. */ -const packageMetadata = JSON.parse(packageResult.stdout); +const packageMetadata = JSON.parse(packageResult.stdout) as PackageMetadata; /* * `npm pack --dry-run --json` has returned an array of package metadata objects on older npm * versions, but newer npm versions instead return an object keyed by package name. Support both @@ -38,13 +51,13 @@ const invalidFiles = packageFiles.filter((file) => !requiredFiles.includes(file) const missingFiles = requiredFiles.filter((file) => !packageFiles.includes(file)); if (invalidFiles.length > 0 || missingFiles.length > 0) { if (invalidFiles.length > 0) { - process.stderr.write(`Unexpected files in package tarball:\n${invalidFiles.map((file) => `- ${file}`).join('\n')}\n`); + process.stderr.write(`Unexpected files in package tarball:\n${ invalidFiles.map((file) => `- ${ file }`).join('\n') }\n`); } if (missingFiles.length > 0) { - process.stderr.write(`Required files missing from package tarball:\n${missingFiles.map((file) => `- ${file}`).join('\n')}\n`); + process.stderr.write(`Required files missing from package tarball:\n${ missingFiles.map((file) => `- ${ file }`).join('\n') }\n`); } process.exitCode = 1; } else { - process.stdout.write(`Package tarball validation passed (${packageFiles.length} files).\n`); + process.stdout.write(`Package tarball validation passed (${ packageFiles.length } files).\n`); } diff --git a/src/index.ts b/src/index.ts index 847c4af..2bb1f5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,12 @@ export { LogEngine } from './LogEngine.js'; -export { - ConsoleDestination -} from './plugins/ConsoleDestination.js'; +export { ConsoleDestination } from './plugins/ConsoleDestination.js'; -export { - FileDestination -} from './plugins/FileDestination.js'; +export { FileDestination } from './plugins/FileDestination.js'; -export { - LogAnalyticsDestination -} from './plugins/LogAnalyticsDestination.js'; +export { LogAnalyticsDestination } from './plugins/LogAnalyticsDestination.js'; -export { - LogLevel -} from './interfaces/LogEngine.js'; +export { LogLevel } from './interfaces/LogEngine.js'; export type { AuditLog, @@ -48,6 +40,4 @@ export type { LogAnalyticsUploaderFactory } from './interfaces/plugins/LogAnalyticsDestination.js'; -export type { - LoggingPluginContract -} from './interfaces/plugins/LoggingPlugin.js'; +export type { LoggingPluginContract } from './interfaces/plugins/LoggingPlugin.js';