diff --git a/apps/api/src/scanner/framework-detection.ts b/apps/api/src/scanner/framework-detection.ts new file mode 100644 index 00000000..48e6977e --- /dev/null +++ b/apps/api/src/scanner/framework-detection.ts @@ -0,0 +1,160 @@ +/** + * Framework detection with confidence metadata (#1170). + * + * Rule selection depends on knowing which framework a repository uses, but + * detection is inherently heuristic. Instead of a bare string, detection + * returns a confidence in [0, 1] and the evidence behind it, so callers can + * ignore weak matches (or surface them for review) rather than acting on a + * coin flip. + * + * Confidence is the sum of weighted evidence, capped at 1. Every check is a + * pure function of the manifest and the file list. + */ + +export type FrameworkId = + | "next" + | "nestjs" + | "react" + | "vue" + | "svelte" + | "express" + | "foundry" + | "hardhat" + | "cargo" + | "unknown"; + +export interface FrameworkSignal { + framework: FrameworkId; + /** Confidence in [0, 1]. */ + confidence: number; + evidence: string[]; +} + +export interface FrameworkDetectionInput { + /** Dependency names from package.json (dependencies + devDependencies). */ + dependencies?: string[]; + /** Repository-relative file paths. */ + files?: string[]; +} + +interface FrameworkRule { + framework: FrameworkId; + /** Dependency name -> weight when present. */ + dependencies: Record; + /** File path predicate -> weight when matched. */ + files: Array<{ test: (path: string) => boolean; weight: number; label: string }>; +} + +const FRAMEWORK_RULES: FrameworkRule[] = [ + { + framework: "next", + dependencies: { next: 0.8 }, + files: [ + { test: (p) => /(^|\/)next\.config\.(js|mjs|ts)$/.test(p), weight: 0.3, label: "next.config" }, + { test: (p) => /(^|\/)app\/.*page\.(tsx|jsx|ts|js)$/.test(p), weight: 0.2, label: "app router page" }, + ], + }, + { + framework: "nestjs", + dependencies: { "@nestjs/core": 0.8 }, + files: [{ test: (p) => /(^|\/)nest-cli\.json$/.test(p), weight: 0.2, label: "nest-cli.json" }], + }, + { + framework: "react", + dependencies: { react: 0.7 }, + files: [{ test: (p) => /\.(tsx|jsx)$/.test(p), weight: 0.2, label: "JSX source" }], + }, + { + framework: "vue", + dependencies: { vue: 0.8 }, + files: [{ test: (p) => p.endsWith(".vue"), weight: 0.2, label: "single-file component" }], + }, + { + framework: "svelte", + dependencies: { svelte: 0.8 }, + files: [{ test: (p) => p.endsWith(".svelte"), weight: 0.2, label: "svelte component" }], + }, + { + framework: "express", + dependencies: { express: 0.7 }, + files: [{ test: (p) => /(^|\/)routes?\//.test(p), weight: 0.1, label: "routes directory" }], + }, + { + framework: "foundry", + dependencies: {}, + files: [ + { test: (p) => /(^|\/)foundry\.toml$/.test(p), weight: 1, label: "foundry.toml" }, + { test: (p) => /(^|\/)test\/.*\.t\.sol$/.test(p), weight: 0.2, label: "foundry test" }, + ], + }, + { + framework: "hardhat", + dependencies: { hardhat: 0.8 }, + files: [{ test: (p) => /(^|\/)hardhat\.config\.(ts|js)$/.test(p), weight: 0.2, label: "hardhat.config" }], + }, + { + framework: "cargo", + dependencies: {}, + files: [{ test: (p) => /(^|\/)Cargo\.toml$/.test(p), weight: 1, label: "Cargo.toml" }], + }, +]; + +export const MIN_FRAMEWORK_CONFIDENCE = 0.2; + +/** + * Detect the frameworks in use, strongest first. + * + * Ties are broken by framework id so the output is deterministic. + */ +export function detectFrameworks(input: FrameworkDetectionInput): FrameworkSignal[] { + const dependencies = new Set( + (input.dependencies ?? []).map((dependency) => dependency.trim().toLowerCase()), + ); + const files = input.files ?? []; + + const signals: FrameworkSignal[] = []; + + for (const rule of FRAMEWORK_RULES) { + const evidence: string[] = []; + let confidence = 0; + + for (const [dependency, weight] of Object.entries(rule.dependencies)) { + if (dependencies.has(dependency.toLowerCase())) { + confidence += weight; + evidence.push(`dependency ${dependency}`); + } + } + + for (const fileRule of rule.files) { + if (files.some((path) => fileRule.test(path))) { + confidence += fileRule.weight; + evidence.push(fileRule.label); + } + } + + if (confidence >= MIN_FRAMEWORK_CONFIDENCE) { + signals.push({ + framework: rule.framework, + confidence: Math.min(1, Number(confidence.toFixed(2))), + evidence, + }); + } + } + + return signals.sort( + (left, right) => + right.confidence - left.confidence || + (left.framework < right.framework ? -1 : 1), + ); +} + +/** The single best match, or an explicit "unknown" signal. */ +export function detectPrimaryFramework(input: FrameworkDetectionInput): FrameworkSignal { + return ( + detectFrameworks(input)[0] ?? { + framework: "unknown", + confidence: 0, + evidence: [], + } + ); +} diff --git a/apps/api/src/scanner/generated-code-exclusions.ts b/apps/api/src/scanner/generated-code-exclusions.ts new file mode 100644 index 00000000..44744c89 --- /dev/null +++ b/apps/api/src/scanner/generated-code-exclusions.ts @@ -0,0 +1,140 @@ +/** + * Generated-code exclusion controls (#1167). + * + * Generated files are excluded from analysis by default, but the decision has + * to be inspectable and overridable: repositories use their own output + * directories, and an operator may need to analyse generated code on purpose. + * Every exclusion therefore carries the rule that caused it, and the config is + * merged with caller-supplied patterns rather than replacing the defaults. + */ + +import { isVendoredPath } from "./vendored-dependency-boundaries"; + +export interface GeneratedCodeExclusionConfig { + /** Glob-ish path patterns, matched with `*` per segment and `**` per tree. */ + patterns: string[]; + /** Content markers that mark a file as generated. */ + markers: string[]; + /** Whether vendored trees are excluded as well. */ + excludeVendored: boolean; +} + +export interface ExclusionDecision { + excluded: boolean; + /** Rule that excluded the file, e.g. "pattern:*.pb.go" or "marker:@generated". */ + reason?: string; +} + +export interface ExclusionReport { + included: string[]; + excluded: Array<{ path: string; reason: string }>; +} + +export const DEFAULT_GENERATED_CODE_EXCLUSIONS: GeneratedCodeExclusionConfig = { + patterns: [ + "**/*.min.js", + "**/*.min.css", + "**/*.pb.go", + "**/*.g.dart", + "**/*.generated.ts", + "**/dist/**", + "**/build/**", + "**/coverage/**", + ], + markers: [ + "@generated", + "code generated by", + "do not edit", + "automatically generated", + ], + excludeVendored: true, +}; + +export function mergeExclusionConfig( + overrides: Partial = {}, +): GeneratedCodeExclusionConfig { + return { + patterns: [ + ...DEFAULT_GENERATED_CODE_EXCLUSIONS.patterns, + ...(overrides.patterns ?? []), + ], + markers: [ + ...DEFAULT_GENERATED_CODE_EXCLUSIONS.markers, + ...(overrides.markers ?? []), + ], + excludeVendored: + overrides.excludeVendored ?? DEFAULT_GENERATED_CODE_EXCLUSIONS.excludeVendored, + }; +} + +/** Convert a path glob into a matcher; `**` spans segments, `*` does not. */ +export function patternToRegExp(pattern: string): RegExp { + const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, ""); + const escaped = normalized + .split("/") + .map((segment) => { + if (segment === "**") { + return "(?:.*)"; + } + return segment + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, "[^/]*"); + }) + .join("/"); + + return new RegExp(`^${escaped}$`); +} + +/** + * Decide whether one file is excluded. + * + * `contentSample` is optional: path rules apply without reading the file, and + * marker rules only run when a sample is supplied. + */ +export function isGeneratedCode( + filePath: string, + contentSample: string | undefined, + config: GeneratedCodeExclusionConfig = DEFAULT_GENERATED_CODE_EXCLUSIONS, +): ExclusionDecision { + const normalized = filePath.replace(/\\/g, "/").replace(/^\.\//, ""); + + if (config.excludeVendored && isVendoredPath(normalized)) { + return { excluded: true, reason: "vendored-boundary" }; + } + + for (const pattern of config.patterns) { + if (patternToRegExp(pattern).test(normalized)) { + return { excluded: true, reason: `pattern:${pattern}` }; + } + } + + if (contentSample) { + const lower = contentSample.toLowerCase(); + const marker = config.markers.find((candidate) => lower.includes(candidate.toLowerCase())); + if (marker) { + return { excluded: true, reason: `marker:${marker}` }; + } + } + + return { excluded: false }; +} + +/** Apply the exclusions to a file list, preserving input order. */ +export function applyGeneratedCodeExclusions( + files: ReadonlyArray<{ path: string; content?: string }>, + config: GeneratedCodeExclusionConfig = DEFAULT_GENERATED_CODE_EXCLUSIONS, +): ExclusionReport { + const included: string[] = []; + const excluded: Array<{ path: string; reason: string }> = []; + + for (const file of files) { + const decision = isGeneratedCode(file.path, file.content, config); + if (decision.excluded) { + excluded.push({ path: file.path, reason: decision.reason ?? "unknown" }); + } else { + included.push(file.path); + } + } + + return { included, excluded }; +} diff --git a/apps/api/src/scanner/vendored-dependency-boundaries.ts b/apps/api/src/scanner/vendored-dependency-boundaries.ts new file mode 100644 index 00000000..28747faa --- /dev/null +++ b/apps/api/src/scanner/vendored-dependency-boundaries.ts @@ -0,0 +1,112 @@ +/** + * Vendored dependency boundaries (#1168). + * + * Third-party code checked into a repository (vendored crates, bundled + * dependencies, a committed `node_modules`) is not first-party source. Findings + * raised inside those trees are noise, and path-based rules must not cross a + * boundary: a file under `vendor/` belongs to its vendor root, not to the + * repository. + * + * Boundaries are detected purely from paths, so the result is stable no matter + * which files are present in a given scan. + */ + +export const VENDOR_DIRECTORY_NAMES = [ + "node_modules", + "vendor", + "vendors", + "third_party", + "third-party", + "deps", + "extern", + "external", + ".yarn", + "target", +]; + +export interface VendoredBoundary { + /** Directory that starts the vendored tree, repository-relative. */ + directory: string; + /** Depth of the boundary from the repository root, 0-based. */ + depth: number; + /** Name of the matching directory segment. */ + marker: string; +} + +export function isVendoredPath( + filePath: string, + extraDirectories: readonly string[] = [], +): boolean { + const segments = normalize(filePath).split("/"); + const markers = new Set( + [...VENDOR_DIRECTORY_NAMES, ...extraDirectories].map((entry) => entry.toLowerCase()), + ); + return segments.some((segment) => markers.has(segment.toLowerCase())); +} + +/** Every vendored boundary present in `paths`, shallowest first. */ +export function findVendoredBoundaries( + paths: readonly string[], + extraDirectories: readonly string[] = [], +): VendoredBoundary[] { + const markers = new Set( + [...VENDOR_DIRECTORY_NAMES, ...extraDirectories].map((entry) => entry.toLowerCase()), + ); + const found = new Map(); + + for (const path of paths) { + const segments = normalize(path).split("/"); + + segments.forEach((segment, index) => { + if (!markers.has(segment.toLowerCase())) { + return; + } + const directory = segments.slice(0, index + 1).join("/"); + if (!found.has(directory)) { + found.set(directory, { directory, depth: index, marker: segment }); + } + }); + } + + return [...found.values()].sort( + (left, right) => + left.depth - right.depth || (left.directory < right.directory ? -1 : 1), + ); +} + +/** Split paths into first-party and vendored, preserving input order. */ +export function splitByVendoredBoundary( + paths: readonly string[], + extraDirectories: readonly string[] = [], +): { firstParty: string[]; vendored: string[] } { + const firstParty: string[] = []; + const vendored: string[] = []; + + for (const path of paths) { + if (isVendoredPath(path, extraDirectories)) { + vendored.push(path); + } else { + firstParty.push(path); + } + } + + return { firstParty, vendored }; +} + +/** + * The vendored root that owns `filePath`, or undefined for first-party code. + */ +export function vendoredRootFor( + filePath: string, + extraDirectories: readonly string[] = [], +): string | undefined { + const normalized = normalize(filePath); + const boundary = findVendoredBoundaries([normalized], extraDirectories).find((candidate) => + normalized.startsWith(`${candidate.directory}/`), + ); + return boundary?.directory; +} + +function normalize(filePath: string): string { + return filePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""); +} diff --git a/apps/api/src/scanner/workspace-package-resolution.ts b/apps/api/src/scanner/workspace-package-resolution.ts new file mode 100644 index 00000000..a2136095 --- /dev/null +++ b/apps/api/src/scanner/workspace-package-resolution.ts @@ -0,0 +1,108 @@ +/** + * Workspace-aware package resolution (#1169). + * + * In a monorepo the same package name can appear in several workspaces, and a + * bare import resolves against the nearest workspace that declares it. This + * module expands the workspace patterns from the root manifest into concrete + * directories and resolves a package name to the workspace that owns it, so + * findings can be attributed to the right package instead of the repository + * root. + * + * No filesystem access: callers pass the directories and manifests they found. + */ + +export interface WorkspacePackage { + /** Package name from the workspace manifest, when it declares one. */ + name?: string; + /** Repository-relative workspace directory. */ + directory: string; +} + +export interface WorkspaceResolution { + packageName: string; + directory?: string; + /** True when more than one workspace declares the same name. */ + ambiguous: boolean; +} + +/** Convert a workspace glob (`packages/*`, `apps/**`) into a matcher. */ +export function workspacePatternToRegExp(pattern: string): RegExp { + const normalized = pattern.replace(/\\/g, "/").replace(/\/+$/, ""); + const escaped = normalized + .split("/") + .map((segment) => { + if (segment === "**") { + return ".*"; + } + if (segment === "*") { + return "[^/]+"; + } + return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }) + .join("/"); + + return new RegExp(`^${escaped}$`); +} + +/** Directories from `candidates` matched by any workspace pattern. */ +export function expandWorkspacePatterns( + patterns: readonly string[], + candidates: readonly string[], +): string[] { + const matchers = patterns.map(workspacePatternToRegExp); + return [...candidates] + .filter((candidate) => { + const normalized = candidate.replace(/\\/g, "/").replace(/\/+$/, ""); + return matchers.some((matcher) => matcher.test(normalized)); + }) + .sort(); +} + +/** + * Resolve a package name to the workspace directory that owns it. + * + * Package names are compared case-insensitively, matching npm's rules. When two + * workspaces declare the same name the result is flagged `ambiguous` and no + * directory is chosen, so the caller can fall back to the repository root. + */ +export function resolvePackageWorkspace( + packageName: string, + workspaces: readonly WorkspacePackage[], +): WorkspaceResolution { + if (typeof packageName !== "string" || packageName.trim() === "") { + throw new TypeError("packageName must be a non-empty string"); + } + + const target = packageName.trim().toLowerCase(); + const matches = workspaces.filter( + (workspace) => workspace.name?.trim().toLowerCase() === target, + ); + + if (matches.length === 0) { + return { packageName, ambiguous: false }; + } + + if (matches.length > 1) { + return { packageName, ambiguous: true }; + } + + return { packageName, directory: matches[0].directory, ambiguous: false }; +} + +/** + * Resolve the workspace that contains a file, by longest directory prefix. + */ +export function workspaceForFile( + filePath: string, + workspaces: readonly WorkspacePackage[], +): WorkspacePackage | undefined { + const normalized = filePath.replace(/\\/g, "/"); + const candidates = workspaces + .filter((workspace) => { + const directory = workspace.directory.replace(/\\/g, "/").replace(/\/+$/, ""); + return normalized === directory || normalized.startsWith(`${directory}/`); + }) + .sort((left, right) => right.directory.length - left.directory.length); + + return candidates[0]; +}