diff --git a/site/src/App.jsx b/site/src/App.jsx
index ef99d60..f984ad6 100644
--- a/site/src/App.jsx
+++ b/site/src/App.jsx
@@ -15,6 +15,22 @@ import { SiteFooter } from "./components/SiteFooter";
gsap.registerPlugin(ScrollTrigger, useGSAP);
+const FLOW_GLOW_INITIAL_Y = -10;
+const REVEAL_LEFT_OFFSET = -34;
+const FLOW_INACTIVE_OPACITY = 0.62;
+const FLOW_ACTIVE_Y = -4;
+const FLOW_ACTIVE_SCALE = 1.02;
+const FLOW_INACTIVE_SCALE = 0.985;
+const FLOW_ACTIVE_DURATION = 0.4;
+const FLOW_INACTIVE_DURATION = 0.34;
+const FLOW_GLOW_BASE_OPACITY = 0.42;
+const FLOW_GLOW_OPACITY_STEP = 0.18;
+const FLOW_GLOW_BASE_Y = -12;
+const FLOW_GLOW_Y_STEP = 8;
+const FLOW_GLOW_BASE_SCALE = 0.96;
+const FLOW_GLOW_SCALE_STEP = 0.06;
+const HERO_PARALLAX_PERCENT = -8;
+
export default function App() {
const rootRef = useRef(null);
@@ -74,7 +90,7 @@ export default function App() {
});
gsap.set(".js-divider-line", { scaleX: 0, transformOrigin: "0% 50%" });
gsap.set(".js-flow-meter", { scaleY: 0, transformOrigin: "50% 0%" });
- gsap.set(".js-flow-glow", { autoAlpha: 0.4, scale: 0.92, y: -10 });
+ gsap.set(".js-flow-glow", { autoAlpha: 0.4, scale: 0.92, y: FLOW_GLOW_INITIAL_Y });
const heroTimeline = gsap.timeline({ defaults: { ease: "power3.out" } });
@@ -122,7 +138,7 @@ export default function App() {
});
gsap.from(".js-reveal-left", {
- x: -34,
+ x: REVEAL_LEFT_OFFSET,
autoAlpha: 0,
duration: 0.7,
ease: "power2.out",
@@ -173,19 +189,19 @@ export default function App() {
stage.classList.toggle("is-active", isActive);
gsap.to(stage, {
- autoAlpha: isActive ? 1 : 0.62,
- y: isActive ? -4 : 0,
- scale: isActive ? 1.02 : 0.985,
- duration: isActive ? 0.4 : 0.34,
+ autoAlpha: isActive ? 1 : FLOW_INACTIVE_OPACITY,
+ y: isActive ? FLOW_ACTIVE_Y : 0,
+ scale: isActive ? FLOW_ACTIVE_SCALE : FLOW_INACTIVE_SCALE,
+ duration: isActive ? FLOW_ACTIVE_DURATION : FLOW_INACTIVE_DURATION,
ease: isActive ? "power3.out" : "power2.out",
overwrite: "auto"
});
});
gsap.to(".js-flow-glow", {
- autoAlpha: 0.42 + activeIndex * 0.18,
- y: -12 + activeIndex * 8,
- scale: 0.96 + activeIndex * 0.06,
+ autoAlpha: FLOW_GLOW_BASE_OPACITY + activeIndex * FLOW_GLOW_OPACITY_STEP,
+ y: FLOW_GLOW_BASE_Y + activeIndex * FLOW_GLOW_Y_STEP,
+ scale: FLOW_GLOW_BASE_SCALE + activeIndex * FLOW_GLOW_SCALE_STEP,
duration: 0.45,
ease: "power2.out",
overwrite: "auto"
@@ -270,7 +286,7 @@ export default function App() {
if (desktop) {
gsap.to(".js-hero-panel", {
- yPercent: -8,
+ yPercent: HERO_PARALLAX_PERCENT,
ease: "none",
scrollTrigger: {
trigger: ".hero",
diff --git a/site/src/components/HeaderHero.jsx b/site/src/components/HeaderHero.jsx
index 5289473..3bcdf52 100644
--- a/site/src/components/HeaderHero.jsx
+++ b/site/src/components/HeaderHero.jsx
@@ -1,87 +1,74 @@
+const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@eduardbar/drift";
+
+function HeroNavigation() {
+ return (
+
+ );
+}
+
+function HeroCopy() {
+ return (
+
+
Static trust audit for TS/JS repos
+
+ Merge with evidence,
+ not intuition.
+
+
+ drift parses your repo with ts-morph, scores structural debt across 35 weighted
+ rules, and turns every PR into a measurable trust signal.
+
+
+ - 35 weighted rules
+ - AST-based analysis
+ - CI ready trust gate
+
+
+
npm i -D @eduardbar/drift
+
+ );
+}
+
+function AuditSnapshot() {
+ return (
+
+ );
+}
+
export function HeaderHero() {
return (
-
-
+
-
-
Static trust audit for TS/JS repos
-
- Merge with evidence,
- not intuition.
-
-
- drift parses your repo with ts-morph, scores structural debt across 35 weighted
- rules, and turns every PR into a measurable trust signal.
-
-
- - 35 weighted rules
- - AST-based analysis
- - CI ready trust gate
-
-
-
npm i -D @eduardbar/drift
-
-
-
+
+
);
diff --git a/src/cli.ts b/src/cli.ts
index 81f70ff..cbe204d 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -162,8 +162,8 @@ addResourceOptions(
.option('--json', 'Output raw JSON report')
.option('--ai', 'Output AI-optimized JSON for LLM consumption')
.option('--fix', 'Show fix suggestions for each issue')
- .option('--min-score ', 'Exit with code 1 if overall score exceeds this threshold', '0')
- .action(async (targetPath: string | undefined, options: { output?: string; format?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
+ .option('--min-score ', 'Exit with code 1 if overall score exceeds this threshold')
+ .action(async (targetPath: string | undefined, options: { output?: string; format?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore?: string } & ResourceOptionFlags) => {
const resolvedPath = resolve(targetPath ?? '.')
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
@@ -185,37 +185,27 @@ addResourceOptions(
if (format === 'sarif') {
process.stdout.write(`${JSON.stringify(toSarif(report), null, 2)}\n`)
- return
- }
-
- if (format === 'ai') {
+ } else if (format === 'ai') {
const aiOutput = formatAIOutput(report)
process.stdout.write(JSON.stringify(aiOutput, null, 2))
- return
- }
-
- if (format === 'json') {
+ } else if (format === 'json') {
process.stdout.write(JSON.stringify(report, null, 2))
- return
- }
-
- if (format === 'markdown') {
+ } else if (format === 'markdown') {
process.stdout.write(`${formatMarkdown(report)}\n`)
- return
- }
-
- printConsole(report, { showFix: options.fix })
+ } else {
+ printConsole(report, { showFix: options.fix })
- if (options.output) {
- const md = formatMarkdown(report)
- const outPath = resolve(options.output)
- writeFileSync(outPath, md, 'utf8')
- // drift-ignore
- console.error(`Report saved to ${outPath}`)
+ if (options.output) {
+ const md = formatMarkdown(report)
+ const outPath = resolve(options.output)
+ writeFileSync(outPath, md, 'utf8')
+ // drift-ignore
+ console.error(`Report saved to ${outPath}`)
+ }
}
const minScore = Number(options.minScore)
- if (minScore > 0 && report.totalScore > minScore) {
+ if (options.minScore !== undefined && report.totalScore > minScore) {
process.exit(1)
}
}),
@@ -901,8 +891,8 @@ addResourceOptions(
.description('Emit GitHub Actions annotations and step summary')
.option('--format ', 'Output format: console|json|markdown|ai|sarif')
.option('--json', 'Output raw JSON report (legacy alias for --format json)')
- .option('--min-score ', 'Exit with code 1 if overall score exceeds this threshold', '0')
- .action(async (targetPath: string | undefined, options: { format?: string; json?: boolean; minScore: string } & ResourceOptionFlags) => {
+ .option('--min-score ', 'Exit with code 1 if overall score exceeds this threshold')
+ .action(async (targetPath: string | undefined, options: { format?: string; json?: boolean; minScore?: string } & ResourceOptionFlags) => {
const resolvedPath = resolve(targetPath ?? '.')
const config = await loadConfig(resolvedPath)
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
@@ -925,7 +915,7 @@ addResourceOptions(
printCISummary(report)
}
const minScore = Number(options.minScore)
- if (minScore > 0 && report.totalScore > minScore) {
+ if (options.minScore !== undefined && report.totalScore > minScore) {
process.exit(1)
}
}),
diff --git a/src/guard.ts b/src/guard.ts
index b8916a1..25d443e 100644
--- a/src/guard.ts
+++ b/src/guard.ts
@@ -132,7 +132,7 @@ export function evaluateGuard(input: GuardEvalInput): GuardEvaluation {
}
}
-export function formatGuardJsonObject(result: GuardResult): GuardResultJson {
+function formatGuardJsonObject(result: GuardResult): GuardResultJson {
return withOutputMetadata(result, OUTPUT_SCHEMA.guard)
}
diff --git a/tests/min-score-gate.test.ts b/tests/min-score-gate.test.ts
new file mode 100644
index 0000000..6e95f74
--- /dev/null
+++ b/tests/min-score-gate.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { spawnSync } from 'node:child_process'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+
+type CliResult = {
+ status: number | null
+ stdout: string
+ stderr: string
+}
+
+function runCli(args: string[]): CliResult {
+ const result = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ })
+
+ return {
+ status: result.status,
+ stdout: result.stdout,
+ stderr: result.stderr,
+ }
+}
+
+describe('min-score gate', () => {
+ let tmpDir = ''
+
+ afterEach(() => {
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
+ tmpDir = ''
+ })
+
+ it('fails scan --min-score 0 while preserving JSON output', () => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-min-score-scan-'))
+ writeFileSync(join(tmpDir, 'sample.ts'), 'console.log("debug")\n')
+
+ const result = runCli(['scan', tmpDir, '--format', 'json', '--min-score', '0'])
+
+ expect(result.status).toBe(1)
+ expect(JSON.parse(result.stdout).totalScore).toBeGreaterThan(0)
+ })
+
+ it('fails ci --min-score 0 while preserving human CI output', () => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-min-score-ci-'))
+ writeFileSync(join(tmpDir, 'sample.ts'), 'console.log("debug")\n')
+
+ const result = runCli(['ci', tmpDir, '--min-score', '0'])
+
+ expect(result.status).toBe(1)
+ expect(result.stdout).toContain('::warning')
+ })
+
+ it('keeps omitted thresholds unchanged and preserves strict positive thresholds', () => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'drift-min-score-positive-'))
+ writeFileSync(join(tmpDir, 'sample.ts'), 'console.log("debug")\n')
+
+ const baseline = runCli(['scan', tmpDir, '--format', 'json'])
+ const score = JSON.parse(baseline.stdout).totalScore as number
+ const exact = runCli(['scan', tmpDir, '--format', 'json', '--min-score', String(score)])
+ const below = runCli(['scan', tmpDir, '--format', 'json', '--min-score', String(Math.max(0, score - 1))])
+
+ expect(baseline.status).toBe(0)
+ expect(exact.status).toBe(0)
+ expect(below.status).toBe(1)
+ expect(JSON.parse(exact.stdout).totalScore).toBe(score)
+ expect(JSON.parse(below.stdout).totalScore).toBe(score)
+ })
+})
diff --git a/tests/phase1-init-doctor-guard.test.ts b/tests/phase1-init-doctor-guard.test.ts
index 157b9fa..fa0ef2b 100644
--- a/tests/phase1-init-doctor-guard.test.ts
+++ b/tests/phase1-init-doctor-guard.test.ts
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runDoctor } from '../src/doctor.js'
import { runInit } from '../src/init.js'
-import { evaluateGuard, formatGuardJsonObject, runGuard } from '../src/guard.js'
+import { evaluateGuard, formatGuardJson, runGuard } from '../src/guard.js'
import { analyzeProject } from '../src/analyzer.js'
import { buildReport } from '../src/reporter.js'
@@ -271,7 +271,7 @@ describe('phase 1: doctor/init/guard', () => {
budget: 0,
bySeverity: { error: 0, warning: 0, info: 0 },
})
- const resultJson = formatGuardJsonObject(result)
+ const resultJson = JSON.parse(formatGuardJson(result)) as Record
const schema = loadSchema('drift-guard.v1.json')
const schemaErrors = validateAgainstSchema(schema, JSON.parse(JSON.stringify(resultJson)))
@@ -284,7 +284,7 @@ describe('phase 1: doctor/init/guard', () => {
expect(result.checks.some((check) => check.id === 'no-regression-total-issues')).toBe(true)
expect(resultJson.$schema).toBe('schemas/drift-guard.v1.json')
expect(typeof resultJson.toolVersion).toBe('string')
- expect(resultJson.toolVersion.length).toBeGreaterThan(0)
+ expect(String(resultJson.toolVersion).length).toBeGreaterThan(0)
expect(schemaErrors).toEqual([])
}, 30000)
diff --git a/tests/site-header-hero.test.ts b/tests/site-header-hero.test.ts
new file mode 100644
index 0000000..b09aeab
--- /dev/null
+++ b/tests/site-header-hero.test.ts
@@ -0,0 +1,56 @@
+import { existsSync } from "node:fs";
+import { createRequire } from "node:module";
+import { resolve } from "node:path";
+import { HeaderHero } from "../site/src/components/HeaderHero.jsx";
+
+const runtimePackageJson = existsSync(resolve(process.cwd(), "site/node_modules/react/package.json"))
+ ? resolve(process.cwd(), "site/package.json")
+ : resolve(process.cwd(), "package.json");
+const runtimeRequire = createRequire(runtimePackageJson);
+const { createElement } = runtimeRequire("react") as typeof import("react");
+const { renderToStaticMarkup } = runtimeRequire("react-dom/server") as typeof import("react-dom/server");
+
+function renderHero() {
+ return renderToStaticMarkup(createElement(HeaderHero));
+}
+
+function renderedLinks(markup: string) {
+ return [...markup.matchAll(/]*)>([\s\S]*?)<\/a>/g)].map(([, attributes, content]) => ({
+ attributes,
+ content: content.replace(/<[^>]+>/g, "").trim(),
+ }));
+}
+
+function attribute(attributes: string, name: string) {
+ return attributes.match(new RegExp(`${name}="([^"]*)"`))?.[1];
+}
+
+describe("HeaderHero rendered contract", () => {
+ test("keeps the npm links and section navigation usable", () => {
+ const links = renderedLinks(renderHero());
+ const npmLinks = links.filter(
+ ({ attributes }) => attribute(attributes, "href") === "https://www.npmjs.com/package/@eduardbar/drift"
+ );
+
+ expect(npmLinks).toHaveLength(2);
+ expect(npmLinks.every(({ attributes }) => attribute(attributes, "target") === "_blank")).toBe(true);
+ expect(npmLinks.every(({ attributes }) => attribute(attributes, "rel") === "noreferrer")).toBe(true);
+ expect(links.some(({ attributes, content }) => attribute(attributes, "href") === "#features" && content === "Features")).toBe(true);
+ expect(links.some(({ attributes, content }) => attribute(attributes, "href") === "#commands" && content === "Commands")).toBe(true);
+ });
+
+ test("keeps the visible proof points and audit metrics", () => {
+ const markup = renderHero();
+
+ expect(markup).toContain("35 weighted rules");
+ expect(markup).toContain("AST-based analysis");
+ expect(markup).toContain("CI ready trust gate");
+ expect(markup).toContain("drift scan src");
+ expect(markup).toContain("drift guard src --budget 3");
+ expect(markup).toContain("drift trust src");
+ expect(markup).toContain("drift trust-gate trust.json");
+ expect(markup).toContain("Rule IDs: 35");
+ expect(markup).toContain("Trust: 84");
+ expect(markup).toContain("Delta risk: +2");
+ });
+});