diff --git a/README.md b/README.md index c3ff703..b36ac49 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ The same operations are exported for direct library use: ```ts import { analyzeTypeScriptImportGraph, + compareTypeScriptQualitySnapshots, planTypeScriptBulkRewrite, + readTypeScriptQualitySnapshot, traceTypeScriptDiagnostics, traceTypeScriptProjectStatus, } from "@evref-bl/dev-nexus-typescript"; @@ -116,6 +118,18 @@ const rewritePlan = planTypeScriptBulkRewrite({ to: "newName", }, }); +const before = readTypeScriptQualitySnapshot({ + projectRoot, + sonarIssuesPath: ".quality/sonar/issues.json", + sonarQualityGatePath: ".quality/sonar/quality-gate.json", + sonarSecurityHotspotsPath: ".quality/sonar/security-hotspots.json", +}); +const after = readTypeScriptQualitySnapshot({ projectRoot }); +const delta = compareTypeScriptQualitySnapshots({ + before, + after, + touchedFiles: ["src/index.ts"], +}); ``` The project-status operation reports setup inventory, available scripts, @@ -139,6 +153,16 @@ categories, risks, and verification commands. Current policy records `applyAllowed: false`; agents can cite the plan before manual edits or future human-approved apply workflows, but this package does not write files. +The quality snapshot operation combines TypeScript diagnostics, import cycles, +and optional Sonar JSON exports from `api/issues/search`, +`api/qualitygates/project_status`, and `api/hotspots/search`. Findings are +grouped by file, rule, and severity. The quality delta operation compares two +snapshots and highlights new or resolved findings on touched files, with special +attention to new bugs, vulnerabilities, security hotspots, and critical or +blocker findings. Rule playbooks cover `typescript:S3776`, `typescript:S5852`, +and `typescript:S4036`; their references point to SonarSource guidance on +cognitive complexity, regex backtracking, quality gates, and PATH trust review. + ## Boundaries - The plugin does not run `npm install`, `pnpm install`, `yarn install`, or diff --git a/src/index.ts b/src/index.ts index d67b92d..a2b7e7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,5 +5,6 @@ export * from "./typeScriptMcpDiagnosticsTracer.js"; export * from "./typeScriptMcpServer.js"; export * from "./typeScriptMcpServerConfig.js"; export * from "./typeScriptProjectSetupInventory.js"; +export * from "./typeScriptQualityFeedback.js"; export * from "./typeScriptWorkerGuidance.js"; export * from "./typeScriptWorkflowSkills.js"; diff --git a/src/typeScriptMcpServer.test.ts b/src/typeScriptMcpServer.test.ts index f0a3bd2..81beea4 100644 --- a/src/typeScriptMcpServer.test.ts +++ b/src/typeScriptMcpServer.test.ts @@ -22,10 +22,12 @@ describe("DevNexus TypeScript MCP server", () => { { name: "typescript.diagnostics" }, { name: "typescript.importGraph" }, { name: "typescript.bulkRewritePlan" }, + { name: "typescript.qualitySnapshot" }, + { name: "typescript.qualityDelta" }, ], }, }); - expect(devNexusTypeScriptMcpTools).toHaveLength(4); + expect(devNexusTypeScriptMcpTools).toHaveLength(6); }); it("returns project status through tools/call", async () => { @@ -63,6 +65,64 @@ describe("DevNexus TypeScript MCP server", () => { }); }); + it("returns a quality delta through tools/call", async () => { + const snapshot = { + operation: "typescript.qualitySnapshot", + readOnly: true, + status: "ok", + projectRoot: process.cwd(), + setup: { + blockerCount: 0, + blockers: [], + }, + inputs: { + diagnostics: true, + importGraph: true, + sonarIssues: false, + sonarQualityGate: false, + sonarSecurityHotspots: false, + }, + summary: { + findingCount: 0, + fileCount: 0, + ruleCount: 0, + criticalOrBlockerCount: 0, + bugCount: 0, + vulnerabilityCount: 0, + securityHotspotCount: 0, + importCycleCount: 0, + qualityGateFailed: false, + }, + findings: [], + findingsByFile: [], + findingsByRule: [], + findingsBySeverity: [], + }; + const response = await handleDevNexusTypeScriptMcpJsonRpcMessage({ + jsonrpc: "2.0", + id: "quality-delta", + method: "tools/call", + params: { + name: "typescript.qualityDelta", + arguments: { + before: snapshot, + after: snapshot, + touchedFiles: [], + }, + }, + }); + + const result = response as { + result: { + content: Array<{ text: string }>; + }; + }; + expect(JSON.parse(result.result.content[0]!.text)).toMatchObject({ + operation: "typescript.qualityDelta", + status: "unchanged", + }); + }); + it("initializes with a stable server identity", async () => { const response = await handleDevNexusTypeScriptMcpJsonRpcMessage({ jsonrpc: "2.0", diff --git a/src/typeScriptMcpServer.ts b/src/typeScriptMcpServer.ts index a27b56e..5e26d52 100644 --- a/src/typeScriptMcpServer.ts +++ b/src/typeScriptMcpServer.ts @@ -17,6 +17,12 @@ import { type TypeScriptMcpTraceInput, } from "./typeScriptMcpDiagnosticsTracer.js"; import { devNexusTypeScriptMcpServerName } from "./typeScriptMcpServerConfig.js"; +import { + compareTypeScriptQualitySnapshots, + readTypeScriptQualitySnapshot, + type TypeScriptQualityDeltaInput, + type TypeScriptQualitySnapshotInput, +} from "./typeScriptQualityFeedback.js"; type JsonRpcId = string | number | null; @@ -114,6 +120,70 @@ export const devNexusTypeScriptMcpTools: readonly McpTool[] = [ additionalProperties: true, }, }, + { + name: "typescript.qualitySnapshot", + description: + "Read TypeScript diagnostics, import cycles, and Sonar JSON into one quality snapshot.", + inputSchema: { + type: "object", + properties: { + projectRoot: projectRootProperty, + tsconfigPath: tsconfigPathProperty, + include: { + type: "array", + items: { type: "string" }, + }, + ignore: { + type: "array", + items: { type: "string" }, + }, + sonarIssuesPath: { + type: "string", + description: "Optional project-relative Sonar api/issues/search JSON path.", + }, + sonarQualityGatePath: { + type: "string", + description: + "Optional project-relative Sonar api/qualitygates/project_status JSON path.", + }, + sonarSecurityHotspotsPath: { + type: "string", + description: + "Optional project-relative Sonar api/hotspots/search JSON path.", + }, + sonar: { + type: "object", + description: "Optional inline Sonar JSON objects for tests or callers.", + }, + }, + required: ["projectRoot"], + additionalProperties: true, + }, + }, + { + name: "typescript.qualityDelta", + description: + "Compare two TypeScript quality snapshots and highlight touched-file regressions.", + inputSchema: { + type: "object", + properties: { + before: { + type: "object", + description: "Baseline quality snapshot.", + }, + after: { + type: "object", + description: "Current quality snapshot.", + }, + touchedFiles: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["before", "after"], + additionalProperties: true, + }, + }, ]; export async function handleDevNexusTypeScriptMcpJsonRpcMessage( @@ -185,6 +255,18 @@ async function callDevNexusTypeScriptMcpTool( params.arguments as unknown as TypeScriptBulkRewritePlanInput, ), ); + case "typescript.qualitySnapshot": + return toolResult( + readTypeScriptQualitySnapshot( + params.arguments as unknown as TypeScriptQualitySnapshotInput, + ), + ); + case "typescript.qualityDelta": + return toolResult( + compareTypeScriptQualitySnapshots( + params.arguments as unknown as TypeScriptQualityDeltaInput, + ), + ); default: return toolResult({ error: `Unknown tool: ${params.name}` }, true); } diff --git a/src/typeScriptQualityFeedback.test.ts b/src/typeScriptQualityFeedback.test.ts new file mode 100644 index 0000000..898c014 --- /dev/null +++ b/src/typeScriptQualityFeedback.test.ts @@ -0,0 +1,277 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + compareTypeScriptQualitySnapshots, + createTypeScriptQualityAnalyzer, + readTypeScriptQualitySnapshot, + typeScriptQualityFeedbackToolDescriptors, + typeScriptQualityRulePlaybooks, + type TypeScriptQualitySnapshot, +} from "./index.js"; + +const tempDirs: string[] = []; + +function makeTempProject(name: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `dev-nexus-ts-quality-${name}-`)); + tempDirs.push(dir); + return dir; +} + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeText(filePath: string, value: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value); +} + +function writeTypeScriptProject( + projectRoot: string, + files: Record, +): void { + writeJson(path.join(projectRoot, "package.json"), { + scripts: { + check: "tsc --noEmit", + }, + devDependencies: { + typescript: "^5.9.0", + }, + }); + writeText(path.join(projectRoot, "package-lock.json"), "{}\n"); + writeJson(path.join(projectRoot, "tsconfig.json"), { + compilerOptions: { + noEmit: true, + skipLibCheck: true, + strict: true, + target: "ES2022", + types: [], + }, + include: ["src/**/*.ts"], + }); + + for (const [filePath, sourceText] of Object.entries(files)) { + writeText(path.join(projectRoot, filePath), sourceText); + } + fs.symlinkSync(path.resolve("node_modules"), path.join(projectRoot, "node_modules")); +} + +function emptySnapshot(projectRoot: string): TypeScriptQualitySnapshot { + return { + operation: "typescript.qualitySnapshot", + readOnly: true, + status: "ok", + projectRoot, + setup: { + blockerCount: 0, + blockers: [], + }, + inputs: { + diagnostics: true, + importGraph: true, + sonarIssues: false, + sonarQualityGate: false, + sonarSecurityHotspots: false, + }, + summary: { + findingCount: 0, + fileCount: 0, + ruleCount: 0, + criticalOrBlockerCount: 0, + bugCount: 0, + vulnerabilityCount: 0, + securityHotspotCount: 0, + importCycleCount: 0, + qualityGateFailed: false, + }, + findings: [], + findingsByFile: [], + findingsByRule: [], + findingsBySeverity: [], + }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("TypeScript quality feedback", () => { + it("exposes read-only quality tools and rule playbooks", () => { + expect(typeScriptQualityFeedbackToolDescriptors).toEqual([ + { + name: "typescript.qualitySnapshot", + description: + "Read TypeScript diagnostics, import cycles, and Sonar JSON into one quality snapshot.", + readOnly: true, + }, + { + name: "typescript.qualityDelta", + description: + "Compare two TypeScript quality snapshots and highlight touched-file regressions.", + readOnly: true, + }, + ]); + + const analyzer = createTypeScriptQualityAnalyzer(); + + expect(Object.keys(analyzer).sort()).toEqual([ + "qualityDelta", + "qualitySnapshot", + "tools", + ]); + expect(typeScriptQualityRulePlaybooks.map((playbook) => playbook.rule)).toEqual([ + "typescript:S3776", + "typescript:S5852", + "typescript:S4036", + ]); + }); + + it("combines diagnostics, import cycles, and Sonar JSON into grouped findings", () => { + const projectRoot = makeTempProject("snapshot"); + writeTypeScriptProject(projectRoot, { + "src/a.ts": [ + "import { b } from './b';", + "export function a(): string { return b(); }", + "", + ].join("\n"), + "src/b.ts": [ + "import { a } from './a';", + "export function b(): string { return a(); }", + "", + ].join("\n"), + "src/c.ts": "export const broken: string = 42;\n", + }); + + const snapshot = readTypeScriptQualitySnapshot({ + projectRoot, + sonar: { + issues: { + issues: [ + { + key: "issue-1", + rule: "typescript:S3776", + severity: "CRITICAL", + type: "CODE_SMELL", + component: "project:src/a.ts", + project: "project", + line: 2, + message: "Reduce cognitive complexity.", + status: "OPEN", + }, + ], + }, + securityHotspots: { + hotspots: [ + { + key: "hotspot-1", + ruleKey: "typescript:S4036", + component: "project:src/b.ts", + project: "project", + line: 1, + message: "Review PATH usage.", + vulnerabilityProbability: "HIGH", + status: "TO_REVIEW", + }, + ], + }, + qualityGate: { + projectStatus: { + status: "ERROR", + conditions: [ + { + status: "ERROR", + metricKey: "new_critical_violations", + actualValue: "1", + comparator: "GT", + errorThreshold: "0", + }, + ], + }, + }, + }, + }); + + expect(snapshot).toMatchObject({ + operation: "typescript.qualitySnapshot", + readOnly: true, + status: "findings", + inputs: { + diagnostics: true, + importGraph: true, + sonarIssues: true, + sonarQualityGate: true, + sonarSecurityHotspots: true, + }, + summary: { + findingCount: 5, + criticalOrBlockerCount: 3, + securityHotspotCount: 1, + importCycleCount: 1, + qualityGateFailed: true, + }, + }); + expect(snapshot.findingsByFile.map((group) => group.filePath)).toEqual([ + "src/a.ts", + "src/b.ts", + "src/c.ts", + ]); + expect(snapshot.findingsByRule.map((group) => group.rule)).toEqual([ + "import-cycle", + "new_critical_violations", + "TS2322", + "typescript:S3776", + "typescript:S4036", + ]); + }); + + it("reports touched-file quality deltas and attention findings", () => { + const projectRoot = makeTempProject("delta"); + writeTypeScriptProject(projectRoot, { + "src/index.ts": "export const value: string = 42;\n", + }); + const after = readTypeScriptQualitySnapshot({ + projectRoot, + sonar: { + issues: { + issues: [ + { + key: "issue-1", + rule: "typescript:S3776", + severity: "CRITICAL", + type: "CODE_SMELL", + component: "project:src/index.ts", + project: "project", + line: 1, + message: "Reduce cognitive complexity.", + }, + ], + }, + }, + }); + + const delta = compareTypeScriptQualitySnapshots({ + before: emptySnapshot(projectRoot), + after, + touchedFiles: ["src/index.ts"], + }); + + expect(delta).toMatchObject({ + operation: "typescript.qualityDelta", + readOnly: true, + status: "regressed", + summary: { + newFindingCount: 2, + touchedNewFindingCount: 2, + newCriticalOrBlockerCount: 1, + }, + }); + expect(delta.attention.map((finding) => finding.rule)).toEqual([ + "typescript:S3776", + ]); + }); +}); diff --git a/src/typeScriptQualityFeedback.ts b/src/typeScriptQualityFeedback.ts new file mode 100644 index 0000000..02943e8 --- /dev/null +++ b/src/typeScriptQualityFeedback.ts @@ -0,0 +1,959 @@ +import fs from "node:fs"; +import path from "node:path"; +import { analyzeTypeScriptImportGraph } from "./typeScriptImportGraphAnalysis.js"; +import { + traceTypeScriptDiagnostics, + type TypeScriptMcpTraceInput, +} from "./typeScriptMcpDiagnosticsTracer.js"; +import type { TypeScriptImportGraphInput } from "./typeScriptImportGraphAnalysis.js"; +import type { TypeScriptSetupFinding } from "./typeScriptProjectSetupInventory.js"; + +export const typeScriptQualityFeedbackToolDescriptors = [ + { + name: "typescript.qualitySnapshot", + description: + "Read TypeScript diagnostics, import cycles, and Sonar JSON into one quality snapshot.", + readOnly: true, + }, + { + name: "typescript.qualityDelta", + description: + "Compare two TypeScript quality snapshots and highlight touched-file regressions.", + readOnly: true, + }, +] as const; + +export interface TypeScriptQualitySnapshotInput extends TypeScriptMcpTraceInput { + include?: string[]; + ignore?: string[]; + sonarIssuesPath?: string; + sonarQualityGatePath?: string; + sonarSecurityHotspotsPath?: string; + sonar?: { + issues?: unknown; + qualityGate?: unknown; + securityHotspots?: unknown; + }; +} + +export interface TypeScriptQualityAnalyzer { + tools: typeof typeScriptQualityFeedbackToolDescriptors; + qualitySnapshot: typeof readTypeScriptQualitySnapshot; + qualityDelta: typeof compareTypeScriptQualitySnapshots; +} + +export type TypeScriptQualityFindingSource = + | "import_graph" + | "sonar_issue" + | "sonar_quality_gate" + | "sonar_security_hotspot" + | "typescript"; + +export type TypeScriptQualityFindingCategory = + | "bug" + | "code_smell" + | "diagnostic" + | "import_cycle" + | "quality_gate" + | "security_hotspot" + | "unknown" + | "vulnerability"; + +export type TypeScriptQualityFindingSeverity = + | "blocker" + | "critical" + | "error" + | "info" + | "major" + | "message" + | "minor" + | "suggestion" + | "unknown" + | "warning"; + +export interface TypeScriptQualityFinding { + id: string; + source: TypeScriptQualityFindingSource; + category: TypeScriptQualityFindingCategory; + severity: TypeScriptQualityFindingSeverity; + filePath: string | null; + line: number | null; + rule: string | null; + message: string; + status?: string; + effort?: string; + relatedFiles?: string[]; +} + +export interface TypeScriptQualityFindingFileGroup { + filePath: string; + findings: TypeScriptQualityFinding[]; +} + +export interface TypeScriptQualityFindingRuleGroup { + rule: string; + count: number; + findings: TypeScriptQualityFinding[]; +} + +export interface TypeScriptQualityFindingSeverityGroup { + severity: TypeScriptQualityFindingSeverity; + count: number; +} + +export interface TypeScriptQualitySnapshot { + operation: "typescript.qualitySnapshot"; + readOnly: true; + status: "blocked" | "findings" | "ok"; + projectRoot: string; + setup: { + blockerCount: number; + blockers: TypeScriptSetupFinding[]; + }; + inputs: { + diagnostics: boolean; + importGraph: boolean; + sonarIssues: boolean; + sonarQualityGate: boolean; + sonarSecurityHotspots: boolean; + }; + summary: { + findingCount: number; + fileCount: number; + ruleCount: number; + criticalOrBlockerCount: number; + bugCount: number; + vulnerabilityCount: number; + securityHotspotCount: number; + importCycleCount: number; + qualityGateFailed: boolean; + }; + findings: TypeScriptQualityFinding[]; + findingsByFile: TypeScriptQualityFindingFileGroup[]; + findingsByRule: TypeScriptQualityFindingRuleGroup[]; + findingsBySeverity: TypeScriptQualityFindingSeverityGroup[]; +} + +export interface TypeScriptQualityDeltaInput { + before: TypeScriptQualitySnapshot; + after: TypeScriptQualitySnapshot; + touchedFiles?: string[]; +} + +export interface TypeScriptQualityDelta { + operation: "typescript.qualityDelta"; + readOnly: true; + status: "regressed" | "improved" | "unchanged"; + touchedFiles: string[]; + summary: { + newFindingCount: number; + resolvedFindingCount: number; + touchedNewFindingCount: number; + touchedResolvedFindingCount: number; + newCriticalOrBlockerCount: number; + newBugCount: number; + newVulnerabilityCount: number; + newSecurityHotspotCount: number; + qualityGateRegressed: boolean; + }; + newFindings: TypeScriptQualityFinding[]; + resolvedFindings: TypeScriptQualityFinding[]; + touchedNewFindings: TypeScriptQualityFinding[]; + touchedResolvedFindings: TypeScriptQualityFinding[]; + attention: TypeScriptQualityFinding[]; +} + +export interface TypeScriptQualityRulePlaybook { + rule: string; + title: string; + preferredFixes: string[]; + deferWhen: string[]; + references: string[]; +} + +interface SonarIssueLike { + key?: unknown; + rule?: unknown; + severity?: unknown; + type?: unknown; + component?: unknown; + project?: unknown; + line?: unknown; + message?: unknown; + status?: unknown; + issueStatus?: unknown; + effort?: unknown; + impacts?: unknown; +} + +interface SonarHotspotLike { + key?: unknown; + ruleKey?: unknown; + component?: unknown; + project?: unknown; + line?: unknown; + message?: unknown; + status?: unknown; + vulnerabilityProbability?: unknown; + securityCategory?: unknown; +} + +interface SonarQualityGateConditionLike { + status?: unknown; + metricKey?: unknown; + actualValue?: unknown; + comparator?: unknown; + errorThreshold?: unknown; +} + +const highSeverities = new Set([ + "blocker", + "critical", +]); + +export const typeScriptQualityRulePlaybooks: TypeScriptQualityRulePlaybook[] = [ + { + rule: "typescript:S3776", + title: "Cognitive complexity", + preferredFixes: [ + "Extract cohesive helpers around named decisions or output construction.", + "Flatten deeply nested branches with early returns or guard clauses.", + "Move repeated condition matrices into data-driven policy tables when that matches the domain.", + "Keep tests around the original behavior before splitting complex functions.", + ], + deferWhen: [ + "The function is generated, adapter glue, or safer to split only after a broader boundary change.", + "The change would only hide branches behind single-use helpers with less readable names.", + ], + references: [ + "https://www.sonarsource.com/resources/cognitive-complexity/", + "https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/", + ], + }, + { + rule: "typescript:S5852", + title: "Regex backtracking risk", + preferredFixes: [ + "Remove nested or overlapping quantifiers where input can be attacker-controlled.", + "Constrain repetition with explicit character classes, anchors, and length bounds.", + "Replace the regex with a parser or linear scan when the accepted language is not simple.", + "Add focused worst-case tests for long non-matching inputs.", + ], + deferWhen: [ + "The regex is only used on trusted, tightly bounded input and the bound is enforced nearby.", + "The replacement needs a domain parser that should be designed separately.", + ], + references: [ + "https://www.sonarsource.com/blog/crafting-regexes-to-avoid-stack-overflows/", + "https://community.sonarsource.com/t/write-efficient-error-free-and-safe-regular-expressions-in-javascript-and-typescript/47720", + ], + }, + { + rule: "typescript:S4036", + title: "PATH trust boundary", + preferredFixes: [ + "Resolve executable paths from trusted configuration or an allowlist instead of ambient PATH.", + "When PATH must be used, sanitize it before process launch and document the trust boundary.", + "Keep shell execution behind one helper so callers cannot bypass the policy.", + "Test that untrusted PATH entries are rejected or ignored.", + ], + deferWhen: [ + "The command is deliberately interactive developer tooling and the trust model is documented.", + "A proper fix depends on a broader credential, runner, or host-policy boundary.", + ], + references: [ + "https://community.sonarsource.com/t/false-positive-for-rule-typescript-s4036/142908", + ], + }, +]; + +export function createTypeScriptQualityAnalyzer(): TypeScriptQualityAnalyzer { + return { + tools: typeScriptQualityFeedbackToolDescriptors, + qualitySnapshot: readTypeScriptQualitySnapshot, + qualityDelta: compareTypeScriptQualitySnapshots, + }; +} + +export function readTypeScriptQualitySnapshot( + input: TypeScriptQualitySnapshotInput, +): TypeScriptQualitySnapshot { + const diagnostics = traceTypeScriptDiagnostics(input); + const importGraph = analyzeTypeScriptImportGraph(importGraphInput(input)); + const blockers = [ + ...diagnostics.setup.blockers, + ...importGraph.setup.blockers, + ]; + const findings = [ + ...diagnosticFindings(diagnostics.diagnostics), + ...importCycleFindings(importGraph.cycles), + ...sonarIssueFindings(input), + ...sonarHotspotFindings(input), + ...sonarQualityGateFindings(input), + ].sort(compareFindings); + + return qualitySnapshot({ + input, + blockers: uniqueBlockers(blockers), + findings, + diagnosticsAvailable: diagnostics.didRunCompiler, + importGraphAvailable: importGraph.didAnalyze, + }); +} + +export function compareTypeScriptQualitySnapshots( + input: TypeScriptQualityDeltaInput, +): TypeScriptQualityDelta { + const touchedFiles = normalizeTouchedFiles(input.touchedFiles); + const before = findingMap(input.before.findings); + const after = findingMap(input.after.findings); + const newFindings = [...after.entries()] + .filter(([key]) => !before.has(key)) + .map(([, finding]) => finding) + .sort(compareFindings); + const resolvedFindings = [...before.entries()] + .filter(([key]) => !after.has(key)) + .map(([, finding]) => finding) + .sort(compareFindings); + const touchedNewFindings = filterTouchedFindings(newFindings, touchedFiles); + const touchedResolvedFindings = filterTouchedFindings(resolvedFindings, touchedFiles); + const attention = newFindings.filter((finding) => isAttentionFinding(finding)); + const qualityGateRegressed = + !input.before.summary.qualityGateFailed && input.after.summary.qualityGateFailed; + + return { + operation: "typescript.qualityDelta", + readOnly: true, + status: deltaStatus(newFindings, resolvedFindings, qualityGateRegressed), + touchedFiles, + summary: { + newFindingCount: newFindings.length, + resolvedFindingCount: resolvedFindings.length, + touchedNewFindingCount: touchedNewFindings.length, + touchedResolvedFindingCount: touchedResolvedFindings.length, + newCriticalOrBlockerCount: newFindings.filter(isCriticalOrBlocker).length, + newBugCount: newFindings.filter((finding) => finding.category === "bug").length, + newVulnerabilityCount: newFindings.filter( + (finding) => finding.category === "vulnerability", + ).length, + newSecurityHotspotCount: newFindings.filter( + (finding) => finding.category === "security_hotspot", + ).length, + qualityGateRegressed, + }, + newFindings, + resolvedFindings, + touchedNewFindings, + touchedResolvedFindings, + attention, + }; +} + +function importGraphInput( + input: TypeScriptQualitySnapshotInput, +): TypeScriptImportGraphInput { + return { + projectRoot: input.projectRoot, + tsconfigPath: input.tsconfigPath, + include: input.include, + ignore: input.ignore, + }; +} + +function qualitySnapshot(input: { + input: TypeScriptQualitySnapshotInput; + blockers: TypeScriptSetupFinding[]; + findings: TypeScriptQualityFinding[]; + diagnosticsAvailable: boolean; + importGraphAvailable: boolean; +}): TypeScriptQualitySnapshot { + const summary = qualitySummary(input.findings); + + return { + operation: "typescript.qualitySnapshot", + readOnly: true, + status: snapshotStatus(input.blockers, input.findings), + projectRoot: input.input.projectRoot, + setup: { + blockerCount: input.blockers.length, + blockers: input.blockers, + }, + inputs: { + diagnostics: input.diagnosticsAvailable, + importGraph: input.importGraphAvailable, + sonarIssues: sonarDataAvailable(input.input, "issues", "sonarIssuesPath"), + sonarQualityGate: sonarDataAvailable( + input.input, + "qualityGate", + "sonarQualityGatePath", + ), + sonarSecurityHotspots: sonarDataAvailable( + input.input, + "securityHotspots", + "sonarSecurityHotspotsPath", + ), + }, + summary, + findings: input.findings, + findingsByFile: findingsByFile(input.findings), + findingsByRule: findingsByRule(input.findings), + findingsBySeverity: findingsBySeverity(input.findings), + }; +} + +function diagnosticFindings( + diagnostics: ReturnType["diagnostics"], +): TypeScriptQualityFinding[] { + return diagnostics.map((diagnostic) => ({ + id: `typescript:${diagnostic.code}:${diagnostic.filePath ?? "project"}:${diagnostic.line ?? 0}:${diagnostic.character ?? 0}`, + source: "typescript", + category: "diagnostic", + severity: diagnostic.category, + filePath: diagnostic.filePath, + line: diagnostic.line, + rule: `TS${diagnostic.code}`, + message: diagnostic.message, + })); +} + +function importCycleFindings( + cycles: ReturnType["cycles"], +): TypeScriptQualityFinding[] { + return cycles.map((cycle, index) => ({ + id: `import-cycle:${index + 1}:${cycle.modules.join(">")}`, + source: "import_graph", + category: "import_cycle", + severity: "major", + filePath: cycle.modules[0] ?? null, + line: null, + rule: "import-cycle", + message: `Import cycle: ${cycle.modules.join(" -> ")}`, + relatedFiles: cycle.modules, + })); +} + +function sonarIssueFindings( + input: TypeScriptQualitySnapshotInput, +): TypeScriptQualityFinding[] { + const data = readSonarInput(input, "issues", "sonarIssuesPath"); + const issues = arrayProperty(data, "issues"); + + return issues.map((issue, index) => { + const rule = stringValue(issue.rule); + const filePath = sonarComponentPath(input.projectRoot, issue.component, issue.project); + const line = numberValue(issue.line); + + return { + id: `sonar-issue:${stringValue(issue.key) ?? `${rule ?? "unknown"}:${filePath ?? "project"}:${line ?? index}`}`, + source: "sonar_issue", + category: sonarIssueCategory(issue), + severity: sonarIssueSeverity(issue), + filePath, + line, + rule, + message: stringValue(issue.message) ?? "Sonar issue", + status: stringValue(issue.issueStatus) ?? stringValue(issue.status) ?? undefined, + effort: stringValue(issue.effort) ?? undefined, + }; + }); +} + +function sonarHotspotFindings( + input: TypeScriptQualitySnapshotInput, +): TypeScriptQualityFinding[] { + const data = readSonarInput(input, "securityHotspots", "sonarSecurityHotspotsPath"); + const hotspots = arrayProperty(data, "hotspots"); + + return hotspots.map((hotspot, index) => { + const filePath = sonarComponentPath( + input.projectRoot, + hotspot.component, + hotspot.project, + ); + const line = numberValue(hotspot.line); + const rule = stringValue(hotspot.ruleKey); + + return { + id: `sonar-hotspot:${stringValue(hotspot.key) ?? `${rule ?? "unknown"}:${filePath ?? "project"}:${line ?? index}`}`, + source: "sonar_security_hotspot", + category: "security_hotspot", + severity: hotspotSeverity(hotspot.vulnerabilityProbability), + filePath, + line, + rule, + message: stringValue(hotspot.message) ?? "Sonar security hotspot", + status: stringValue(hotspot.status) ?? undefined, + }; + }); +} + +function sonarQualityGateFindings( + input: TypeScriptQualitySnapshotInput, +): TypeScriptQualityFinding[] { + const data = readSonarInput(input, "qualityGate", "sonarQualityGatePath"); + const projectStatus = objectProperty(data, "projectStatus"); + const gateStatus = stringValue(projectStatus?.status); + const conditions = arrayProperty( + projectStatus, + "conditions", + ); + + return conditions + .filter((condition) => stringValue(condition.status) === "ERROR") + .map((condition, index) => ({ + id: `sonar-quality-gate:${stringValue(condition.metricKey) ?? index}`, + source: "sonar_quality_gate", + category: "quality_gate", + severity: gateStatus === "ERROR" ? "critical" : "major", + filePath: null, + line: null, + rule: stringValue(condition.metricKey), + message: qualityGateMessage(condition), + status: stringValue(condition.status) ?? undefined, + })); +} + +function readSonarInput( + input: TypeScriptQualitySnapshotInput, + dataKey: keyof NonNullable, + pathKey: + | "sonarIssuesPath" + | "sonarQualityGatePath" + | "sonarSecurityHotspotsPath", +): unknown { + const inline = input.sonar?.[dataKey]; + if (inline !== undefined) { + return inline; + } + + const filePath = input[pathKey]; + if (!filePath) { + return null; + } + + return JSON.parse(fs.readFileSync(path.resolve(input.projectRoot, filePath), "utf8")); +} + +function sonarDataAvailable( + input: TypeScriptQualitySnapshotInput, + dataKey: keyof NonNullable, + pathKey: + | "sonarIssuesPath" + | "sonarQualityGatePath" + | "sonarSecurityHotspotsPath", +): boolean { + return input.sonar?.[dataKey] !== undefined || Boolean(input[pathKey]); +} + +function sonarIssueSeverity( + issue: SonarIssueLike, +): TypeScriptQualityFindingSeverity { + const impactSeverity = sonarImpactSeverity(issue.impacts); + if (impactSeverity) { + return impactSeverity; + } + + return normalizeSeverity(stringValue(issue.severity)); +} + +function sonarIssueCategory(issue: SonarIssueLike): TypeScriptQualityFindingCategory { + const type = stringValue(issue.type)?.toUpperCase(); + if (type === "BUG") { + return "bug"; + } + if (type === "VULNERABILITY") { + return "vulnerability"; + } + if (type === "SECURITY_HOTSPOT") { + return "security_hotspot"; + } + if (type === "CODE_SMELL") { + return "code_smell"; + } + + return "unknown"; +} + +function sonarImpactSeverity( + impacts: unknown, +): TypeScriptQualityFindingSeverity | null { + if (!Array.isArray(impacts)) { + return null; + } + + const severities = impacts + .map((impact) => + impact && typeof impact === "object" + ? normalizeSeverity(stringValue((impact as Record).severity)) + : "unknown", + ) + .filter((severity) => severity !== "unknown"); + + return severities.sort(compareSeverity).at(0) ?? null; +} + +function hotspotSeverity(value: unknown): TypeScriptQualityFindingSeverity { + switch (stringValue(value)?.toUpperCase()) { + case "HIGH": + return "critical"; + case "MEDIUM": + return "major"; + case "LOW": + return "minor"; + default: + return "unknown"; + } +} + +function normalizeSeverity( + value: string | null | undefined, +): TypeScriptQualityFindingSeverity { + switch (value?.toUpperCase()) { + case "BLOCKER": + return "blocker"; + case "CRITICAL": + case "HIGH": + return "critical"; + case "ERROR": + return "error"; + case "MAJOR": + case "MEDIUM": + return "major"; + case "MINOR": + case "LOW": + return "minor"; + case "INFO": + return "info"; + case "WARNING": + return "warning"; + case "MESSAGE": + return "message"; + case "SUGGESTION": + return "suggestion"; + default: + return "unknown"; + } +} + +function sonarComponentPath( + projectRoot: string, + component: unknown, + project: unknown, +): string | null { + const componentValue = stringValue(component); + if (!componentValue) { + return null; + } + + const projectPrefix = stringValue(project); + const withoutProject = + projectPrefix && componentValue.startsWith(`${projectPrefix}:`) + ? componentValue.slice(projectPrefix.length + 1) + : componentValue.includes(":") + ? componentValue.slice(componentValue.indexOf(":") + 1) + : componentValue; + const relative = path.isAbsolute(withoutProject) + ? path.relative(projectRoot, withoutProject) + : withoutProject; + + return normalizePath(relative); +} + +function qualityGateMessage(condition: SonarQualityGateConditionLike): string { + const metric = stringValue(condition.metricKey) ?? "quality gate metric"; + const actual = stringValue(condition.actualValue) ?? "unknown"; + const comparator = stringValue(condition.comparator) ?? "failed"; + const threshold = stringValue(condition.errorThreshold) ?? "threshold"; + + return `Quality gate failed for ${metric}: ${actual} ${comparator} ${threshold}`; +} + +function qualitySummary(findings: TypeScriptQualityFinding[]): TypeScriptQualitySnapshot["summary"] { + const filePaths = new Set( + findings + .map((finding) => finding.filePath) + .filter((filePath): filePath is string => filePath !== null), + ); + const rules = new Set( + findings + .map((finding) => finding.rule) + .filter((rule): rule is string => rule !== null), + ); + + return { + findingCount: findings.length, + fileCount: filePaths.size, + ruleCount: rules.size, + criticalOrBlockerCount: findings.filter(isCriticalOrBlocker).length, + bugCount: findings.filter((finding) => finding.category === "bug").length, + vulnerabilityCount: findings.filter( + (finding) => finding.category === "vulnerability", + ).length, + securityHotspotCount: findings.filter( + (finding) => finding.category === "security_hotspot", + ).length, + importCycleCount: findings.filter( + (finding) => finding.category === "import_cycle", + ).length, + qualityGateFailed: findings.some( + (finding) => finding.source === "sonar_quality_gate", + ), + }; +} + +function findingsByFile( + findings: TypeScriptQualityFinding[], +): TypeScriptQualityFindingFileGroup[] { + const grouped = new Map(); + for (const finding of findings) { + if (!finding.filePath) { + continue; + } + const group = grouped.get(finding.filePath) ?? []; + group.push(finding); + grouped.set(finding.filePath, group); + } + + return [...grouped.entries()] + .map(([filePath, groupFindings]) => ({ + filePath, + findings: groupFindings.sort(compareFindings), + })) + .sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +function findingsByRule( + findings: TypeScriptQualityFinding[], +): TypeScriptQualityFindingRuleGroup[] { + const grouped = new Map(); + for (const finding of findings) { + const rule = finding.rule ?? "unruled"; + const group = grouped.get(rule) ?? []; + group.push(finding); + grouped.set(rule, group); + } + + return [...grouped.entries()] + .map(([rule, groupFindings]) => ({ + rule, + count: groupFindings.length, + findings: groupFindings.sort(compareFindings), + })) + .sort((left, right) => left.rule.localeCompare(right.rule)); +} + +function findingsBySeverity( + findings: TypeScriptQualityFinding[], +): TypeScriptQualityFindingSeverityGroup[] { + const grouped = new Map(); + for (const finding of findings) { + grouped.set(finding.severity, (grouped.get(finding.severity) ?? 0) + 1); + } + + return [...grouped.entries()] + .map(([severity, count]) => ({ severity, count })) + .sort((left, right) => compareSeverity(left.severity, right.severity)); +} + +function findingMap( + findings: TypeScriptQualityFinding[], +): Map { + return new Map(findings.map((finding) => [findingSignature(finding), finding])); +} + +function findingSignature(finding: TypeScriptQualityFinding): string { + return [ + finding.source, + finding.category, + finding.severity, + finding.rule ?? "", + finding.filePath ?? "", + finding.line ?? "", + finding.message, + ].join("|"); +} + +function filterTouchedFindings( + findings: TypeScriptQualityFinding[], + touchedFiles: string[], +): TypeScriptQualityFinding[] { + if (touchedFiles.length === 0) { + return []; + } + + const touched = new Set(touchedFiles); + return findings.filter((finding) => + finding.filePath ? touched.has(finding.filePath) : false, + ); +} + +function normalizeTouchedFiles(files: string[] | undefined): string[] { + if (!files) { + return []; + } + + return [...new Set(files.map(normalizePath))].sort(); +} + +function snapshotStatus( + blockers: TypeScriptSetupFinding[], + findings: TypeScriptQualityFinding[], +): TypeScriptQualitySnapshot["status"] { + if (blockers.length > 0 && findings.length === 0) { + return "blocked"; + } + + return findings.length > 0 ? "findings" : "ok"; +} + +function deltaStatus( + newFindings: TypeScriptQualityFinding[], + resolvedFindings: TypeScriptQualityFinding[], + qualityGateRegressed: boolean, +): TypeScriptQualityDelta["status"] { + if (newFindings.length > 0 || qualityGateRegressed) { + return "regressed"; + } + if (resolvedFindings.length > 0) { + return "improved"; + } + return "unchanged"; +} + +function isAttentionFinding(finding: TypeScriptQualityFinding): boolean { + return ( + isCriticalOrBlocker(finding) || + finding.category === "bug" || + finding.category === "vulnerability" || + finding.category === "security_hotspot" + ); +} + +function isCriticalOrBlocker(finding: TypeScriptQualityFinding): boolean { + return highSeverities.has(finding.severity); +} + +function uniqueBlockers( + blockers: TypeScriptSetupFinding[], +): TypeScriptSetupFinding[] { + return [...new Map(blockers.map((blocker) => [blocker.id, blocker])).values()]; +} + +function arrayProperty(value: unknown, property: string): T[] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return []; + } + + const propertyValue = (value as Record)[property]; + if (Array.isArray(propertyValue)) { + return propertyValue as T[]; + } + + return []; +} + +function objectProperty( + value: unknown, + property: string, +): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const propertyValue = (value as Record)[property]; + if (!propertyValue || typeof propertyValue !== "object" || Array.isArray(propertyValue)) { + return null; + } + + return propertyValue as Record; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function normalizePath(filePath: string): string { + return path.normalize(filePath).split(path.sep).join("/"); +} + +function compareFindings( + left: TypeScriptQualityFinding, + right: TypeScriptQualityFinding, +): number { + return ( + compareNullableString(left.filePath, right.filePath) || + compareNullableNumber(left.line, right.line) || + compareSeverity(left.severity, right.severity) || + compareNullableString(left.rule, right.rule) || + left.source.localeCompare(right.source) || + left.message.localeCompare(right.message) + ); +} + +function compareSeverity( + left: TypeScriptQualityFindingSeverity, + right: TypeScriptQualityFindingSeverity, +): number { + return severityRank(left) - severityRank(right); +} + +function severityRank(severity: TypeScriptQualityFindingSeverity): number { + switch (severity) { + case "blocker": + return 0; + case "critical": + return 1; + case "error": + return 2; + case "major": + return 3; + case "warning": + return 4; + case "minor": + return 5; + case "info": + return 6; + case "suggestion": + return 7; + case "message": + return 8; + case "unknown": + return 9; + } +} + +function compareNullableString( + left: string | null, + right: string | null, +): number { + if (left === right) { + return 0; + } + if (left === null) { + return 1; + } + if (right === null) { + return -1; + } + return left.localeCompare(right); +} + +function compareNullableNumber( + left: number | null, + right: number | null, +): number { + if (left === right) { + return 0; + } + if (left === null) { + return 1; + } + if (right === null) { + return -1; + } + return left - right; +}