Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions apps/api/src/scanner/framework-detection.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
/** 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: [],
}
);
}
140 changes: 140 additions & 0 deletions apps/api/src/scanner/generated-code-exclusions.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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 };
}
Loading
Loading