From ef8a042e254bbf0ce84797d816d4fbb77b7fdb0c Mon Sep 17 00:00:00 2001 From: lindsay-cheng Date: Tue, 9 Jun 2026 11:44:22 -0400 Subject: [PATCH 1/2] feat: add Lizard, jscpd, and docs/CI file checks to scan pipeline --- THIRD-PARTY-NOTICES.md | 70 ++++++++++++ apps/api/package.json | 1 + apps/api/src/scanner/applicability.ts | 2 +- apps/api/src/scanner/detect-files.ts | 18 +++ apps/api/src/scanner/scoring/code-quality.ts | 64 +++++++++++ apps/api/src/scanner/tools/cicd-check.ts | 55 ++++++++++ apps/api/src/scanner/tools/docs-check.ts | 100 +++++++++++++++++ apps/api/src/scanner/tools/jscpd.ts | 94 ++++++++++++++++ apps/api/src/scanner/tools/lizard.ts | 110 +++++++++++++++++++ apps/api/src/scanner/tools/opengrep.ts | 11 +- apps/api/src/scanner/types.ts | 7 ++ apps/api/src/worker/scan-processor.ts | 34 +++++- docker/api.Dockerfile | 6 + pnpm-lock.yaml | 8 ++ 14 files changed, 569 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/scanner/scoring/code-quality.ts create mode 100644 apps/api/src/scanner/tools/cicd-check.ts create mode 100644 apps/api/src/scanner/tools/docs-check.ts create mode 100644 apps/api/src/scanner/tools/jscpd.ts create mode 100644 apps/api/src/scanner/tools/lizard.ts diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d0c6be3..204457e 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -717,3 +717,73 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +--- + +## Lizard + +- **Version:** v1.22.2 +- **Project URL:** https://github.com/terryyin/lizard +- **Source code:** https://github.com/terryyin/lizard +- **License:** MIT + +GitAGrip invokes Lizard as a standalone Python package installed via pip in the API container. + +### MIT License Text + +MIT License + +Copyright (c) 2011-2026 Terry Yin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +## jscpd + +- **Version:** v4.2.5 +- **Project URL:** https://github.com/kucherenko/jscpd +- **Source code:** https://github.com/kucherenko/jscpd +- **License:** MIT + +GitAGrip invokes jscpd as a standalone npm global package in the API container. + +### MIT License Text + +MIT License + +Copyright (c) 2013-2026 Andriy Kucherenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/api/package.json b/apps/api/package.json index ca9a0c7..8e975f0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@gitagrip/shared": "workspace:*", "bullmq": "^5.0.0", "cookie-parser": "^1.4.7", + "csv-parse": "^5.6.0", "cors": "^2.8.5", "drizzle-orm": "^0.45.1", "express": "^5.0.0", diff --git a/apps/api/src/scanner/applicability.ts b/apps/api/src/scanner/applicability.ts index 469f942..d2d09e6 100644 --- a/apps/api/src/scanner/applicability.ts +++ b/apps/api/src/scanner/applicability.ts @@ -28,7 +28,7 @@ export function getCategoryApplicability(manifest: FileManifest): CategoryApplic // conditional dependency_health: manifest.hasLockFiles, code_quality: manifest.supportedLanguageFiles > 0, - cicd_devops: manifest.hasCIConfig, + cicd_devops: manifest.hasCIConfig || manifest.hasHusky || manifest.hasPreCommit, workflow_security: manifest.hasWorkflowFiles, iac_security: manifest.hasIaCFiles, dockerfile_best_practices: manifest.hasDockerfile, diff --git a/apps/api/src/scanner/detect-files.ts b/apps/api/src/scanner/detect-files.ts index f48b6d9..fc4398f 100644 --- a/apps/api/src/scanner/detect-files.ts +++ b/apps/api/src/scanner/detect-files.ts @@ -10,6 +10,10 @@ export interface FileManifest { hasLockFiles: boolean; hasCIConfig: boolean; hasWorkflowFiles: boolean; + hasHusky: boolean; + hasPreCommit: boolean; + hasCodeowners: boolean; + workflowFileCount: number; hasReadme: boolean; hasLicense: boolean; hasContributing: boolean; @@ -73,6 +77,10 @@ export async function detectFiles(repoDir: string): Promise { hasLockFiles: false, hasCIConfig: false, hasWorkflowFiles: false, + hasHusky: false, + hasPreCommit: false, + hasCodeowners: false, + workflowFileCount: 0, hasReadme: false, hasLicense: false, hasContributing: false, @@ -104,6 +112,7 @@ export async function detectFiles(repoDir: string): Promise { if (stats.isSymbolicLink()) continue; if (stats.isDirectory()) { + if (entry.name === '.husky') manifest.hasHusky = true; await walk(fullPath); continue; } @@ -133,14 +142,23 @@ export async function detectFiles(repoDir: string): Promise { ) { manifest.hasCIConfig = true; manifest.hasWorkflowFiles = true; + manifest.workflowFileCount++; } if (CI_FILES.has(fileNameRaw)) manifest.hasCIConfig = true; if (CI_DIRS.some((d) => relativePath.startsWith(d + '/'))) manifest.hasCIConfig = true; const dir = path.dirname(relativePath); + + if (fileName === 'codeowners' && (dir === '.' || dir === '.github')) { + manifest.hasCodeowners = true; + } const isTopLevelOrGithub = dir === '.' || dir === '.github'; + if (dir === '.' && fileNameRaw === '.pre-commit-config.yaml') { + manifest.hasPreCommit = true; + } + if (isTopLevelOrGithub) { if (/^readme(\..+)?$/i.test(fileNameRaw)) manifest.hasReadme = true; if (/^(license|copying)(\..+)?$/i.test(fileNameRaw)) manifest.hasLicense = true; diff --git a/apps/api/src/scanner/scoring/code-quality.ts b/apps/api/src/scanner/scoring/code-quality.ts new file mode 100644 index 0000000..73e5581 --- /dev/null +++ b/apps/api/src/scanner/scoring/code-quality.ts @@ -0,0 +1,64 @@ +// combines Lizard + jscpd partial scores into published code_quality category + +import { + clampScore, + notApplicableScore, + type CategoryScore, + type PartialToolScore, +} from '../types.js'; + +export function combineCodeQuality( + lizard: PartialToolScore, + jscpd: PartialToolScore, + applicable: boolean, +): CategoryScore { + if (!applicable) { + return notApplicableScore('code_quality', 'No supported source files detected'); + } + + const lizardOk = !lizard.failed; + const jscpdOk = !jscpd.failed; + + if (!lizardOk && !jscpdOk) { + const reasons: string[] = []; + if (lizard.failed) reasons.push(`lizard ${lizard.failureReason ?? 'failed'}`); + if (jscpd.failed) reasons.push(`jscpd ${jscpd.failureReason ?? 'failed'}`); + return { + category: 'code_quality', + score: 0, + applicable: true, + message: `Tool failed: ${reasons.join('; ')}`, + findingCount: 0, + }; + } + + let score: number; + const parts: string[] = []; + const unavailable: string[] = []; + + if (lizardOk && jscpdOk) { + score = clampScore(lizard.score * 0.6 + jscpd.score * 0.4); + parts.push(lizard.detail, jscpd.detail); + } else if (lizardOk) { + score = lizard.score; + parts.push(lizard.detail); + unavailable.push('duplication unavailable'); + } else { + score = jscpd.score; + parts.push(jscpd.detail); + unavailable.push('complexity unavailable'); + } + + let message = parts.filter(Boolean).join(', '); + if (unavailable.length > 0) { + message = message ? `${message} (${unavailable.join(', ')})` : unavailable.join(', '); + } + + return { + category: 'code_quality', + score, + applicable: true, + message, + findingCount: 0, + }; +} diff --git a/apps/api/src/scanner/tools/cicd-check.ts b/apps/api/src/scanner/tools/cicd-check.ts new file mode 100644 index 0000000..1487fe3 --- /dev/null +++ b/apps/api/src/scanner/tools/cicd-check.ts @@ -0,0 +1,55 @@ +// pure Node.js CI/CD and DevOps file scorer + +import type { CategoryApplicability } from '../applicability.js'; +import type { FileManifest } from '../detect-files.js'; +import { + clampScore, + notApplicableScore, + type CategoryScore, + type ToolRunContext, +} from '../types.js'; + +export async function runCICDCheck( + ctx: ToolRunContext & { manifest: FileManifest; applicability: CategoryApplicability }, +): Promise { + const { manifest, applicability } = ctx; + + if (!applicability.cicd_devops) { + return [notApplicableScore('cicd_devops', 'No CI or DevOps tooling detected')]; + } + + let score = 0; + const signals: string[] = []; + + if (manifest.hasCIConfig) { + score += 40; + signals.push('CI config'); + } + if (manifest.hasWorkflowFiles) { + score += 20; + signals.push('GitHub Actions'); + } + if (manifest.workflowFileCount >= 2) { + score += 10; + signals.push(`${manifest.workflowFileCount} workflows`); + } + if (manifest.hasCodeowners) { + score += 15; + signals.push('CODEOWNERS'); + } + if (manifest.hasHusky || manifest.hasPreCommit) { + score += 15; + if (manifest.hasHusky) signals.push('husky'); + if (manifest.hasPreCommit) signals.push('pre-commit'); + } + + return [ + { + category: 'cicd_devops', + score: clampScore(score), + applicable: true, + message: signals.length > 0 ? signals.join(', ') : 'No CI/DevOps signals detected', + findingCount: signals.length, + }, + ]; +} diff --git a/apps/api/src/scanner/tools/docs-check.ts b/apps/api/src/scanner/tools/docs-check.ts new file mode 100644 index 0000000..f76260f --- /dev/null +++ b/apps/api/src/scanner/tools/docs-check.ts @@ -0,0 +1,100 @@ +// pure Node.js documentation standards scorer + +import { readFile, readdir } from 'fs/promises'; +import path from 'path'; +import type { FileManifest } from '../detect-files.js'; +import { clampScore, type CategoryScore, type ToolRunContext } from '../types.js'; + +async function findRootReadme(repoDir: string): Promise { + const entries = await readdir(repoDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (/^readme(\..+)?$/i.test(entry.name)) { + return path.join(repoDir, entry.name); + } + } + return null; +} + +function scoreReadmeQuality(content: string): { points: number; signals: string[] } { + const signals: string[] = []; + let points = 0; + + if (content.length > 500) { + points += 5; + signals.push('substantial length'); + } + if (/^#{1,6}\s/m.test(content)) { + points += 5; + signals.push('has headings'); + } + if (/```/.test(content)) { + points += 5; + signals.push('has code blocks'); + } + + return { points, signals }; +} + +export async function runDocsCheck( + ctx: ToolRunContext & { manifest: FileManifest }, +): Promise { + const { repoDir, manifest } = ctx; + + let score = 0; + const present: string[] = []; + + if (manifest.hasReadme) { + score += 25; + present.push('README'); + } + if (manifest.hasLicense) { + score += 20; + present.push('LICENSE'); + } + if (manifest.hasContributing) { + score += 10; + present.push('CONTRIBUTING'); + } + if (manifest.hasChangelog) { + score += 10; + present.push('CHANGELOG'); + } + if (manifest.hasCodeOfConduct) { + score += 5; + present.push('CODE_OF_CONDUCT'); + } + if (manifest.hasSecurityPolicy) { + score += 15; + present.push('SECURITY'); + } + + const qualitySignals: string[] = []; + if (manifest.hasReadme) { + try { + const readmePath = await findRootReadme(repoDir); + if (readmePath) { + const content = await readFile(readmePath, 'utf8'); + const quality = scoreReadmeQuality(content); + score += quality.points; + qualitySignals.push(...quality.signals); + } + } catch { + // existence points already awarded from manifest + } + } + + const parts: string[] = []; + if (present.length > 0) parts.push(`Found: ${present.join(', ')}`); + if (qualitySignals.length > 0) parts.push(`README quality: ${qualitySignals.join(', ')}`); + + return [ + { + category: 'documentation_standards', + score: clampScore(score), + applicable: true, + message: parts.length > 0 ? parts.join('; ') : 'No documentation files detected', + findingCount: present.length, + }, + ]; +} diff --git a/apps/api/src/scanner/tools/jscpd.ts b/apps/api/src/scanner/tools/jscpd.ts new file mode 100644 index 0000000..7cfd07e --- /dev/null +++ b/apps/api/src/scanner/tools/jscpd.ts @@ -0,0 +1,94 @@ +// jscpd runner and parser — duplication partial score for code_quality + +import { readFile, rm } from 'fs/promises'; +import path from 'path'; +import { runTool } from '../run-tool.js'; +import { clampScore, type PartialToolScore, type ToolRunContext } from '../types.js'; + +const REPORT_DIR = '/tmp'; + +interface JscpdStatistic { + percentage?: number; + clones?: number; + duplicatedLines?: number; +} + +interface JscpdReport { + statistics?: { + total?: JscpdStatistic; + }; +} + +function failedPartial(reason: string): PartialToolScore { + return { score: 0, detail: '', failed: true, failureReason: reason }; +} + +function scoreDuplication(percentage: number): number { + if (percentage < 3) return 100; + if (percentage < 5) return 90; + if (percentage < 10) return 75; + if (percentage < 20) return 50; + return 20; +} + +async function parseReport(reportPath: string): Promise { + const raw = await readFile(reportPath, 'utf8'); + if (!raw.trim()) return { percentage: 0, clones: 0, duplicatedLines: 0 }; + + const parsed = JSON.parse(raw) as JscpdReport; + return parsed.statistics?.total ?? { percentage: 0, clones: 0, duplicatedLines: 0 }; +} + +export async function runJscpd(ctx: ToolRunContext): Promise { + const { repoDir, scanId, logger, signal } = ctx; + const outputDir = path.join(REPORT_DIR, `jscpd-${scanId}`); + const reportPath = path.join(outputDir, 'jscpd-report.json'); + + try { + const result = await runTool({ + cmd: 'jscpd', + args: [ + repoDir, + '--reporters', + 'json', + '--output', + outputDir, + '--ignore', + '**/node_modules/**,**/.git/**,**/vendor/**,**/dist/**', + '--gitignore', + '--silent', + ], + label: 'jscpd', + logger, + signal, + }); + + if (result.status === 'timeout') return failedPartial('jscpd timed out'); + if (result.status === 'crash') return failedPartial('jscpd crashed'); + + let stats: JscpdStatistic | null; + try { + stats = await parseReport(reportPath); + } catch { + return failedPartial('jscpd JSON parse error'); + } + + if (!stats) return failedPartial('jscpd JSON parse error'); + + const percentage = stats.percentage ?? 0; + const clones = stats.clones ?? 0; + const score = clampScore(scoreDuplication(percentage)); + + return { + score, + detail: `${percentage.toFixed(1)}% duplication (${clones} clones)`, + failed: false, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'jscpd parse error'; + logger.error('tool', 'jscpd parser failed', { error: message }); + return failedPartial(message); + } finally { + await rm(outputDir, { recursive: true, force: true }); + } +} diff --git a/apps/api/src/scanner/tools/lizard.ts b/apps/api/src/scanner/tools/lizard.ts new file mode 100644 index 0000000..0cc2c05 --- /dev/null +++ b/apps/api/src/scanner/tools/lizard.ts @@ -0,0 +1,110 @@ +// lizard runner and parser — cyclomatic complexity partial score for code_quality + +import { parse } from 'csv-parse/sync'; +import { runTool } from '../run-tool.js'; +import { clampScore, type PartialToolScore, type ToolRunContext } from '../types.js'; + +const EXCLUSIONS = [ + './node_modules/*', + './vendor/*', + './.git/*', + './dist/*', + './.next/*', + './build/*', + './target/*', +]; + +interface LizardRow { + CCN?: string; +} + +function failedPartial(reason: string): PartialToolScore { + return { score: 0, detail: '', failed: true, failureReason: reason }; +} + +function scoreComplexity(avgCcn: number, pctAbove15: number, maxCcn: number): number { + let score = 100; + + if (avgCcn > 15) score -= 30; + else if (avgCcn >= 10) score -= 15; + + if (pctAbove15 > 20) score -= 20; + if (maxCcn > 40) score -= 10; + + return clampScore(score); +} + +function parseCsv(stdout: string): { avgCcn: number; pctAbove15: number; maxCcn: number } | null { + if (!stdout.trim()) { + return { avgCcn: 0, pctAbove15: 0, maxCcn: 0 }; + } + + const rows = parse(stdout, { + columns: true, + skip_empty_lines: true, + relax_column_count: true, + }) as LizardRow[]; + + const ccnValues: number[] = []; + for (const row of rows) { + const ccn = Number(row.CCN); + if (!Number.isFinite(ccn)) continue; + ccnValues.push(ccn); + } + + if (ccnValues.length === 0) { + return { avgCcn: 0, pctAbove15: 0, maxCcn: 0 }; + } + + const sum = ccnValues.reduce((acc, v) => acc + v, 0); + const above15 = ccnValues.filter((v) => v > 15).length; + + return { + avgCcn: sum / ccnValues.length, + pctAbove15: (above15 / ccnValues.length) * 100, + maxCcn: Math.max(...ccnValues), + }; +} + +export async function runLizard(ctx: ToolRunContext): Promise { + const { repoDir, logger, signal } = ctx; + + const args = [ + repoDir, + ...EXCLUSIONS.flatMap((pattern) => ['-x', pattern]), + '--csv', + '-t', + '2', + '-i', + '-1', + ]; + + try { + const result = await runTool({ + cmd: 'lizard', + args, + label: 'lizard', + logger, + signal, + }); + + if (result.status === 'timeout') return failedPartial('lizard timed out'); + if (result.status === 'crash') return failedPartial('lizard crashed'); + + const stats = parseCsv(result.stdout); + if (!stats) return failedPartial('lizard CSV parse error'); + + const score = scoreComplexity(stats.avgCcn, stats.pctAbove15, stats.maxCcn); + const avgLabel = stats.avgCcn.toFixed(1); + + return { + score, + detail: `Avg complexity: ${avgLabel} CCN`, + failed: false, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'lizard parse error'; + logger.error('tool', 'lizard parser failed', { error: message }); + return failedPartial(message); + } +} diff --git a/apps/api/src/scanner/tools/opengrep.ts b/apps/api/src/scanner/tools/opengrep.ts index 3a55846..6959852 100644 --- a/apps/api/src/scanner/tools/opengrep.ts +++ b/apps/api/src/scanner/tools/opengrep.ts @@ -113,9 +113,9 @@ function buildFailureScores(applicability: CategoryApplicability, reason: string ? { category, score: 0, applicable: true, message: `Tool failed: ${reason}`, findingCount: 0 } : notApplicableScore(category, naReason); + // code_quality bucket computed internally but not published — Lizard+jscpd own that category return [ fail('security_vulnerabilities', applicability.security_vulnerabilities, 'N/A'), - fail('code_quality', applicability.code_quality, 'No supported source files detected'), fail('repo_security_posture', applicability.repo_security_posture, 'N/A'), ]; } @@ -174,6 +174,9 @@ export async function runOpengrep( buckets[category][severity]++; } + // code_quality bucket kept in buckets for future re-enable; not pushed to categoryScores[] + void buckets.code_quality; + return [ buildCategoryScore( 'security_vulnerabilities', @@ -181,12 +184,6 @@ export async function runOpengrep( applicability.security_vulnerabilities, 'N/A', ), - buildCategoryScore( - 'code_quality', - buckets.code_quality, - applicability.code_quality, - 'No supported source files detected', - ), buildCategoryScore( 'repo_security_posture', buckets.repo_security_posture, diff --git a/apps/api/src/scanner/types.ts b/apps/api/src/scanner/types.ts index f174a83..8e7d0d6 100644 --- a/apps/api/src/scanner/types.ts +++ b/apps/api/src/scanner/types.ts @@ -18,6 +18,13 @@ export interface ToolRunContext { signal?: AbortSignal; } +export interface PartialToolScore { + score: number; + detail: string; + failed: boolean; + failureReason?: string; +} + export function clampScore(score: number): number { return Math.max(0, Math.min(100, Math.round(score))); } diff --git a/apps/api/src/worker/scan-processor.ts b/apps/api/src/worker/scan-processor.ts index cfdd2bb..1a88193 100644 --- a/apps/api/src/worker/scan-processor.ts +++ b/apps/api/src/worker/scan-processor.ts @@ -12,10 +12,19 @@ import { detectFiles } from '../scanner/detect-files.js'; import { getCategoryApplicability } from '../scanner/applicability.js'; import { cleanupRepo } from '../scanner/cleanup.js'; import { killAllToolProcesses } from '../scanner/run-tool.js'; +import { combineCodeQuality } from '../scanner/scoring/code-quality.js'; +import { runCICDCheck } from '../scanner/tools/cicd-check.js'; +import { runDocsCheck } from '../scanner/tools/docs-check.js'; import { runGitleaks } from '../scanner/tools/gitleaks.js'; +import { runJscpd } from '../scanner/tools/jscpd.js'; +import { runLizard } from '../scanner/tools/lizard.js'; import { runTrivy } from '../scanner/tools/trivy.js'; import { runOpengrep } from '../scanner/tools/opengrep.js'; -import { hasUsableCategoryData, type CategoryScore } from '../scanner/types.js'; +import { + hasUsableCategoryData, + type CategoryScore, + type PartialToolScore, +} from '../scanner/types.js'; interface ScanJobData { scanId: string; @@ -113,10 +122,29 @@ export default async function scanProcessor(job: Job) { // TODO (issue #15): run scorecard }; + const skippedPartial = (): PartialToolScore => ({ + score: 0, + detail: '', + failed: true, + failureReason: 'skipped', + }); + const phaseB = async () => { - const gitleaksScores = await runGitleaks(toolCtx); + const runQualityTools = applicability.code_quality; + const [gitleaksScores, lizardResult, jscpdResult, docsScores, cicdScores] = await Promise.all( + [ + runGitleaks(toolCtx), + runQualityTools ? runLizard(toolCtx) : Promise.resolve(skippedPartial()), + runQualityTools ? runJscpd(toolCtx) : Promise.resolve(skippedPartial()), + runDocsCheck({ ...toolCtx, manifest }), + runCICDCheck({ ...toolCtx, manifest, applicability }), + ], + ); categoryScores.push(...gitleaksScores); - // TODO (issue #14): jscpd, lizard + categoryScores.push( + combineCodeQuality(lizardResult, jscpdResult, applicability.code_quality), + ); + categoryScores.push(...docsScores, ...cicdScores); }; await Promise.all([phaseA(), phaseB()]); diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index 69badbd..f54bdd7 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -70,6 +70,12 @@ RUN mkdir -p /var/lib/trivy /home/appuser/.cache/opengrep \ && chown -R appuser:nodejs /var/lib/trivy /home/appuser/.cache RUN chown -R appuser:nodejs /opt/opengrep-rules +ARG LIZARD_VERSION=1.22.2 +ARG JSCPD_VERSION=4.2.5 +RUN apk add --no-cache python3 py3-pip \ + && pip install --break-system-packages "lizard==${LIZARD_VERSION}" \ + && npm install -g "jscpd@${JSCPD_VERSION}" + COPY --from=builder --chown=appuser:nodejs /prod/api ./ COPY --from=builder --chown=appuser:nodejs /app/apps/api/dist ./dist COPY --from=builder --chown=appuser:nodejs /app/THIRD-PARTY-NOTICES.md ./THIRD-PARTY-NOTICES.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2c959d..4360f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 + csv-parse: + specifier: ^5.6.0 + version: 5.6.0 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@types/pg@8.18.0)(pg@8.19.0) @@ -1617,6 +1620,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + csv-parse@5.6.0: + resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} + dargs@8.1.0: resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} engines: {node: '>=12'} @@ -4019,6 +4025,8 @@ snapshots: csstype@3.2.3: {} + csv-parse@5.6.0: {} + dargs@8.1.0: {} debug@4.4.3: From c7167758fb48a5819188a5e1e7ff6b2713700142 Mon Sep 17 00:00:00 2001 From: lindsay-cheng Date: Tue, 23 Jun 2026 23:49:54 -0400 Subject: [PATCH 2/2] fix: lizard version, jscpd var naming, lizard ccn, third party notices typo --- THIRD-PARTY-NOTICES.md | 4 ++-- apps/api/src/scanner/tools/jscpd.ts | 4 ++-- apps/api/src/scanner/tools/lizard.ts | 16 +++++++++++----- docker/api.Dockerfile | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 204457e..7d62186 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -722,7 +722,7 @@ END OF TERMS AND CONDITIONS ## Lizard -- **Version:** v1.22.2 +- **Version:** v1.9.25 - **Project URL:** https://github.com/terryyin/lizard - **Source code:** https://github.com/terryyin/lizard - **License:** MIT @@ -768,7 +768,7 @@ GitAGrip invokes jscpd as a standalone npm global package in the API container. MIT License -Copyright (c) 2013-2026 Andriy Kucherenko +Copyright (c) 2013-2024 Andrey Kucherenko Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/apps/api/src/scanner/tools/jscpd.ts b/apps/api/src/scanner/tools/jscpd.ts index 7cfd07e..8d91750 100644 --- a/apps/api/src/scanner/tools/jscpd.ts +++ b/apps/api/src/scanner/tools/jscpd.ts @@ -14,7 +14,7 @@ interface JscpdStatistic { } interface JscpdReport { - statistics?: { + statistic?: { total?: JscpdStatistic; }; } @@ -36,7 +36,7 @@ async function parseReport(reportPath: string): Promise { if (!raw.trim()) return { percentage: 0, clones: 0, duplicatedLines: 0 }; const parsed = JSON.parse(raw) as JscpdReport; - return parsed.statistics?.total ?? { percentage: 0, clones: 0, duplicatedLines: 0 }; + return parsed.statistic?.total ?? { percentage: 0, clones: 0, duplicatedLines: 0 }; } export async function runJscpd(ctx: ToolRunContext): Promise { diff --git a/apps/api/src/scanner/tools/lizard.ts b/apps/api/src/scanner/tools/lizard.ts index 0cc2c05..5c94fcc 100644 --- a/apps/api/src/scanner/tools/lizard.ts +++ b/apps/api/src/scanner/tools/lizard.ts @@ -34,9 +34,11 @@ function scoreComplexity(avgCcn: number, pctAbove15: number, maxCcn: number): nu return clampScore(score); } -function parseCsv(stdout: string): { avgCcn: number; pctAbove15: number; maxCcn: number } | null { +function parseCsv( + stdout: string, +): { avgCcn: number; pctAbove15: number; pctAbove25: number; maxCcn: number } | null { if (!stdout.trim()) { - return { avgCcn: 0, pctAbove15: 0, maxCcn: 0 }; + return { avgCcn: 0, pctAbove15: 0, pctAbove25: 0, maxCcn: 0 }; } const rows = parse(stdout, { @@ -53,15 +55,17 @@ function parseCsv(stdout: string): { avgCcn: number; pctAbove15: number; maxCcn: } if (ccnValues.length === 0) { - return { avgCcn: 0, pctAbove15: 0, maxCcn: 0 }; + return { avgCcn: 0, pctAbove15: 0, pctAbove25: 0, maxCcn: 0 }; } const sum = ccnValues.reduce((acc, v) => acc + v, 0); const above15 = ccnValues.filter((v) => v > 15).length; + const above25 = ccnValues.filter((v) => v > 25).length; return { avgCcn: sum / ccnValues.length, pctAbove15: (above15 / ccnValues.length) * 100, + pctAbove25: (above25 / ccnValues.length) * 100, maxCcn: Math.max(...ccnValues), }; } @@ -70,7 +74,7 @@ export async function runLizard(ctx: ToolRunContext): Promise const { repoDir, logger, signal } = ctx; const args = [ - repoDir, + '.', ...EXCLUSIONS.flatMap((pattern) => ['-x', pattern]), '--csv', '-t', @@ -83,6 +87,7 @@ export async function runLizard(ctx: ToolRunContext): Promise const result = await runTool({ cmd: 'lizard', args, + cwd: repoDir, label: 'lizard', logger, signal, @@ -96,10 +101,11 @@ export async function runLizard(ctx: ToolRunContext): Promise const score = scoreComplexity(stats.avgCcn, stats.pctAbove15, stats.maxCcn); const avgLabel = stats.avgCcn.toFixed(1); + const above25Label = stats.pctAbove25.toFixed(1); return { score, - detail: `Avg complexity: ${avgLabel} CCN`, + detail: `Avg complexity: ${avgLabel} CCN, ${above25Label}% functions > 25 CCN`, failed: false, }; } catch (err) { diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index f54bdd7..0e8d31f 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -70,7 +70,7 @@ RUN mkdir -p /var/lib/trivy /home/appuser/.cache/opengrep \ && chown -R appuser:nodejs /var/lib/trivy /home/appuser/.cache RUN chown -R appuser:nodejs /opt/opengrep-rules -ARG LIZARD_VERSION=1.22.2 +ARG LIZARD_VERSION=1.9.25 ARG JSCPD_VERSION=4.2.5 RUN apk add --no-cache python3 py3-pip \ && pip install --break-system-packages "lizard==${LIZARD_VERSION}" \