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
349 changes: 302 additions & 47 deletions dist/index.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions dist/index.js.map

Large diffs are not rendered by default.

143 changes: 137 additions & 6 deletions src/analysis/cli-check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { runCLI } from "../util/cli.js";
import { Severity, type SQLIssue, type ChangedFile } from "./types.js";
import {
Severity,
type SQLIssue,
type ChangedFile,
type ValidationSummary,
type CategorySummary,
} from "./types.js";
import * as core from "@actions/core";

/** Structured output from `altimate-code check --format json`. */
Expand Down Expand Up @@ -46,6 +52,61 @@ export interface CheckCommandOptions {
severity?: string;
}

/** Result from `runCheckCommand` including issues and validation metadata. */
export interface CheckCommandResult {
issues: SQLIssue[];
validationSummary: ValidationSummary;
}

/** Static metadata about each check category for display in PR comments. */
export const CATEGORY_META: Record<
string,
{ label: string; method: string; ruleCount: number; examples: string[] }
> = {
lint: {
label: "Anti-Patterns",
ruleCount: 26,
method: "AST analysis",
examples: ["SELECT *", "cartesian joins", "missing GROUP BY", "non-deterministic functions"],
},
safety: {
label: "Injection Safety",
ruleCount: 10,
method: "Pattern scan",
examples: ["SQL injection", "stacked queries", "tautology", "UNION-based"],
},
validate: {
label: "SQL Syntax",
ruleCount: 0,
method: "DataFusion",
examples: [],
},
pii: {
label: "PII Exposure",
ruleCount: 9,
method: "Column classification",
examples: ["email", "SSN", "phone", "credit card", "IP address"],
},
policy: {
label: "Policy Guardrails",
ruleCount: 0,
method: "YAML policy rules",
examples: [],
},
semantic: {
label: "Semantic Checks",
ruleCount: 10,
method: "Plan analysis",
examples: ["cartesian products", "wrong JOINs", "NULL misuse"],
},
grade: {
label: "Quality Grade",
ruleCount: 0,
method: "Composite scoring",
examples: [],
},
};

/**
* Detect whether the `altimate-code` CLI is available and supports the
* `check` subcommand. Returns true if the CLI responds to `check --help`.
Expand All @@ -60,15 +121,16 @@ export async function isCheckCommandAvailable(): Promise<boolean> {
}

/**
* Run `altimate-code check` on the given files and return structured issues.
* Run `altimate-code check` on the given files and return structured issues
* along with a validation summary that describes what was checked and how.
*
* Invokes the CLI once with all files and all requested checks, parses the
* JSON output, and maps findings to the common `SQLIssue[]` format.
*/
export async function runCheckCommand(
files: ChangedFile[],
options: CheckCommandOptions = {},
): Promise<SQLIssue[]> {
): Promise<CheckCommandResult> {
const filePaths = files.map((f) => f.filename);
const checksArg = (options.checks ?? ["lint", "safety"]).join(",");

Expand All @@ -91,15 +153,84 @@ export async function runCheckCommand(

if (result.exitCode !== 0 && !result.json) {
core.warning(`altimate-code check failed (exit ${result.exitCode}): ${result.stderr}`);
return [];
return { issues: [], validationSummary: buildEmptyValidationSummary(checksArg.split(",")) };
}

if (!result.json) {
core.warning("altimate-code check produced no JSON output");
return [];
return { issues: [], validationSummary: buildEmptyValidationSummary(checksArg.split(",")) };
}

const output = result.json as CheckOutput;
return {
issues: parseCheckOutput(output),
validationSummary: extractValidationSummary(output),
};
}

/**
* Extract a structured validation summary from CLI check output.
* This captures what was checked, how, and whether each category passed.
*/
export function extractValidationSummary(output: CheckOutput): ValidationSummary {
const categories: Record<string, CategorySummary> = {};

const checksRun = output.checks_run ?? [];
const schemaResolved = output.schema_resolved ?? false;

for (const check of checksRun) {
const meta = CATEGORY_META[check];
const result = output.results?.[check];
const findingsCount = result?.findings?.length ?? 0;

if (meta) {
const methodWithContext =
check === "validate" && schemaResolved && output.files_checked > 0
? `${meta.method} against ${output.files_checked} table schemas`
: meta.method;

categories[check] = {
label: meta.ruleCount > 0 ? `${meta.label} (${meta.ruleCount} rules)` : meta.label,
method:
findingsCount === 0 && meta.examples.length > 0
? `${methodWithContext}: ${meta.examples.join(", ")}, ...`
: methodWithContext,
rulesChecked: meta.ruleCount,
findingsCount,
passed: findingsCount === 0,
};
} else {
categories[check] = {
label: check.charAt(0).toUpperCase() + check.slice(1),
method: "Static analysis",
rulesChecked: 0,
findingsCount,
passed: findingsCount === 0,
};
}
}

return parseCheckOutput(result.json as CheckOutput);
return { checksRun, schemaResolved, categories };
}

/** Build a minimal validation summary when CLI output is unavailable. */
function buildEmptyValidationSummary(checks: string[]): ValidationSummary {
const categories: Record<string, CategorySummary> = {};
for (const check of checks) {
const meta = CATEGORY_META[check];
categories[check] = {
label: meta
? meta.ruleCount > 0
? `${meta.label} (${meta.ruleCount} rules)`
: meta.label
: check,
method: meta?.method ?? "Static analysis",
rulesChecked: meta?.ruleCount ?? 0,
findingsCount: 0,
passed: true,
};
}
return { checksRun: checks, schemaResolved: false, categories };
}

/**
Expand Down
88 changes: 88 additions & 0 deletions src/analysis/query-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { QueryProfile } from "./types.js";

/**
* Extract structural metadata from a SQL file using regex-based heuristics.
* This gives users a "Query Profile" showing complexity, JOINs, CTEs, etc.
*/
export function extractQueryProfile(file: string, sql: string): QueryProfile {
const joinTypes = extractJoinTypes(sql);

return {
file,
complexity: computeComplexity(sql),
tablesReferenced: countTables(sql),
joinCount: joinTypes.length,
joinTypes: [...new Set(joinTypes)],
hasAggregation: /\bGROUP\s+BY\b/i.test(sql) || hasAggregateFunctions(sql),
hasSubquery: /\(\s*SELECT\b/i.test(sql),
hasWindowFunction: /\bOVER\s*\(/i.test(sql),
hasCTE: /\bWITH\s+\w+\s+AS\s*\(/i.test(sql),
};
}

/** Count the number of JOIN clauses by type. */
function extractJoinTypes(sql: string): string[] {
const types: string[] = [];
const joinPattern =
/\b(INNER|LEFT\s+OUTER|RIGHT\s+OUTER|FULL\s+OUTER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\b/gi;
let match;
while ((match = joinPattern.exec(sql)) !== null) {
const prefix = (match[1] ?? "INNER").trim().toUpperCase();
// Normalize multi-word types
if (prefix.startsWith("LEFT")) types.push("LEFT");
else if (prefix.startsWith("RIGHT")) types.push("RIGHT");
else if (prefix.startsWith("FULL")) types.push("FULL");
else if (prefix === "CROSS") types.push("CROSS");
else types.push("INNER");
}
return types;
}

/** Count tables referenced via FROM and JOIN clauses. */
function countTables(sql: string): number {
const tables = new Set<string>();

// FROM <table>
const fromPattern = /\bFROM\s+([a-zA-Z_][\w.]*)/gi;
let match;
while ((match = fromPattern.exec(sql)) !== null) {
tables.add(match[1].toLowerCase());
}

// JOIN <table>
const joinPattern = /\bJOIN\s+([a-zA-Z_][\w.]*)/gi;
while ((match = joinPattern.exec(sql)) !== null) {
tables.add(match[1].toLowerCase());
}

return tables.size;
}

/** Check for aggregate functions like COUNT, SUM, AVG, MIN, MAX. */
function hasAggregateFunctions(sql: string): boolean {
return /\b(COUNT|SUM|AVG|MIN|MAX)\s*\(/i.test(sql);
}

/** Estimate query complexity based on structural features. */
function computeComplexity(sql: string): "Low" | "Medium" | "High" {
let score = 0;

const joinCount = (sql.match(/\bJOIN\b/gi) ?? []).length;
score += joinCount;

if (/\bGROUP\s+BY\b/i.test(sql)) score += 1;
if (/\bHAVING\b/i.test(sql)) score += 1;
if (/\bOVER\s*\(/i.test(sql)) score += 2;
if (/\(\s*SELECT\b/i.test(sql)) score += 2;
if (/\bWITH\s+\w+\s+AS\s*\(/i.test(sql)) score += 1;
if (/\bUNION\b/i.test(sql)) score += 2;
if (/\bCASE\b/i.test(sql)) score += 1;

// Count number of CTEs
const cteCount = (sql.match(/\bAS\s*\(\s*SELECT\b/gi) ?? []).length;
if (cteCount > 2) score += 1;

if (score <= 2) return "Low";
if (score <= 5) return "Medium";
return "High";
}
6 changes: 3 additions & 3 deletions src/analysis/sql-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ async function analyzeWithRuleEngine(
severity: altimateConfig.sql_review.severity_threshold,
dialect: altimateConfig.dialect !== "auto" ? altimateConfig.dialect : undefined,
};
const issues = await runCheckCommand(files, options);
core.info(`CLI check found ${issues.length} issue(s) total`);
return issues;
const result = await runCheckCommand(files, options);
core.info(`CLI check found ${result.issues.length} issue(s) total`);
return result.issues;
}

// Fallback: built-in regex rules
Expand Down
50 changes: 50 additions & 0 deletions src/analysis/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,52 @@ export interface CostEstimate {
explanation?: string;
}

/** Summary of what was validated across all check categories. */
export interface ValidationSummary {
/** List of check categories that were executed. */
checksRun: string[];
/** Whether table schemas were resolved for SQL validation. */
schemaResolved: boolean;
/** Per-category validation metadata. */
categories: Record<string, CategorySummary>;
}

/** Metadata for a single check category (e.g. lint, safety, pii). */
export interface CategorySummary {
/** Display label, e.g. "Anti-Patterns (26 rules)". */
label: string;
/** Technology/method used, e.g. "AST analysis: SELECT *, cartesian joins, ...". */
method: string;
/** Number of rules checked in this category. */
rulesChecked: number;
/** Number of findings produced. */
findingsCount: number;
/** Whether this category passed (zero findings). */
passed: boolean;
}

/** Structural metadata extracted from a SQL file. */
export interface QueryProfile {
/** Relative file path. */
file: string;
/** Estimated complexity bucket. */
complexity: "Low" | "Medium" | "High";
/** Number of tables/sources referenced. */
tablesReferenced: number;
/** Number of JOIN clauses. */
joinCount: number;
/** Types of JOINs used (e.g. ["INNER", "LEFT"]). */
joinTypes: string[];
/** Whether the query uses GROUP BY / aggregate functions. */
hasAggregation: boolean;
/** Whether the query contains a subquery. */
hasSubquery: boolean;
/** Whether the query uses window functions (OVER). */
hasWindowFunction: boolean;
/** Whether the query uses CTEs (WITH ... AS). */
hasCTE: boolean;
}

/** Aggregated review report for the entire PR. */
export interface ReviewReport {
/** All SQL issues found across files. */
Expand All @@ -88,6 +134,10 @@ export interface ReviewReport {
mode: ReviewMode;
/** Timestamp of the analysis. */
timestamp: string;
/** Structured validation summary from CLI check output. */
validationSummary?: ValidationSummary;
/** Query structure profiles extracted from SQL content. */
queryProfiles?: QueryProfile[];
}

/** Parsed input configuration for the action. */
Expand Down
Loading
Loading