diff --git a/package.json b/package.json index 4a4c793..7f12e40 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "test:integration": "npm run test:integration -w imhotep-playwright", "test:e2e": "npm run test:e2e -w imhotep-fixtures", "test:external-smoke": "node scripts/external-smoke.mjs", + "test:structural": "node scripts/check-structural.js", + "test:structural:fix": "node scripts/check-structural.js --fix-baseline", + "ci:verify": "bash scripts/ci-verify.sh", "lint": "eslint packages/*/src/**/*.ts", "clean": "rm -rf packages/*/dist packages/*/*.tsbuildinfo dist && find packages -path '*/src/*.js' -delete && find packages -path '*/src/*.d.ts' -delete && find packages -path '*/src/*.map' -delete" }, diff --git a/packages/imhotep-core/src/geometry-cache.test.ts b/packages/imhotep-core/src/geometry-cache.test.ts index df98a5d..f150cb1 100644 --- a/packages/imhotep-core/src/geometry-cache.test.ts +++ b/packages/imhotep-core/src/geometry-cache.test.ts @@ -13,6 +13,7 @@ import { clearGeometryCache, readCachedExtractionResult, writeCachedExtractionResult, + WORLD_CACHE_SCHEMA_VERSION, } from './geometry-cache.js' import { createEmptyWorld } from './world.js' import type { GeometryWorld } from './world.js' @@ -319,4 +320,10 @@ describe('geometry cache', () => { rmSync(cacheDir, { recursive: true, force: true }) } }) + + test('cache key includes schema version for auto-invalidation', () => { + const key = computeGeometryCacheKey('https://example.com', ['.a'], { viewportWidth: 1280 }) + assert.ok(key.startsWith(`${WORLD_CACHE_SCHEMA_VERSION}-`), + `cache key "${key}" should start with schema version ${WORLD_CACHE_SCHEMA_VERSION}`) + }) }) diff --git a/scripts/.structural-baseline.json b/scripts/.structural-baseline.json new file mode 100644 index 0000000..26cb708 --- /dev/null +++ b/scripts/.structural-baseline.json @@ -0,0 +1,5 @@ +{ + "asAny": 101, + "emptyCatch": 17, + "nullishZero": 62 +} diff --git a/scripts/check-structural.js b/scripts/check-structural.js new file mode 100644 index 0000000..7203d65 --- /dev/null +++ b/scripts/check-structural.js @@ -0,0 +1,109 @@ +/** + * Structural conformance gate. + * + * Counts production-source code-smells and compares against committed baselines. + * Nonzero exit code = a baseline was violated. The script does NOT alter + * files; it only audits and reports. + * + * Usage: node scripts/check-structural.js [--fix-baseline] + */ + +import { readFileSync, writeFileSync } from 'fs' +import { fileURLToPath } from 'url' +import glob from 'glob' +import path from 'path' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..') +const BASELINE_PATH = path.join(ROOT, 'scripts', '.structural-baseline.json') + +const PACKAGE_DIRS = glob.sync('packages/*', { cwd: ROOT, absolute: true }) + .filter(d => !d.match(/(imhotep-fixtures|imhotep-bench|imhotep-cli)$/)) + +function srcFiles(pkgDir) { + return glob.sync('src/**/*.ts', { cwd: pkgDir, absolute: true }) + .filter(f => !f.endsWith('.test.ts') && !f.endsWith('.d.ts')) +} + +function countPattern(files, pattern, excludeComment = false) { + let count = 0 + const locations = [] + for (const f of files) { + const lines = readFileSync(f, 'utf8').split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (excludeComment && line.trim().startsWith('//')) continue + const matches = line.match(pattern) + if (matches) { + count += matches.length + locations.push(`${path.relative(ROOT, f)}:${i + 1}`) + } + } + } + return { count, locations } +} + +const allFiles = PACKAGE_DIRS.flatMap(d => srcFiles(d)) +const pkgNames = PACKAGE_DIRS.map(d => path.basename(d)) + +const violations = { + asAny: countPattern(allFiles, /\bas any\b/g, true), + emptyCatch: countPattern(allFiles, /\bcatch\s*\{/), + nullishZero: countPattern(allFiles, /\?\?\s*0(?![.\d])/, true), +} + +const baseline = (() => { + try { return JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) } + catch { return {} } +})() + +const results = { + timestamp: new Date().toISOString(), + packages: pkgNames, + violations, + baseline, +} + +const errors = [] + +for (const [key, current] of Object.entries(violations)) { + const allowed = baseline[key] ?? 0 + const delta = current.count - allowed + const status = delta <= 0 ? 'PASS' : 'FAIL' + const msg = ` ${key.padEnd(18)} current=${String(current.count).padStart(3)} baseline=${String(allowed).padStart(3)} delta=${String(delta).padStart(4)} ${status}` + console.log(msg) + if (status === 'FAIL') { + errors.push(`${key} (${current.count} > baseline ${allowed})`) + console.log(` New locations:`) + for (const loc of current.locations.slice(0, 10)) { + console.log(` ${loc}`) + } + if (current.locations.length > 10) { + console.log(` ... and ${current.locations.length - 10} more`) + } + } +} + +const fixBaseline = process.argv.includes('--fix-baseline') +if (fixBaseline) { + const newBaseline = {} + for (const [key, v] of Object.entries(violations)) { + newBaseline[key] = v.count + } + writeFileSync(BASELINE_PATH, JSON.stringify(newBaseline, null, 2) + '\n') + console.log(`\n Baseline written to ${path.relative(ROOT, BASELINE_PATH)}`) + console.log('Structural gate PASSED (baseline updated)') + process.exit(0) +} + +writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n') + +if (errors.length > 0) { + console.error(`\nStructural gate FAILED (${errors.length} violation(s)):`) + for (const e of errors) console.error(` - ${e}`) + console.error(`\nIf these are legitimate, update the baseline with:`) + console.error(` node scripts/check-structural.js --fix-baseline`) + process.exit(1) +} else { + console.log('\nStructural gate PASSED') +} diff --git a/scripts/ci-verify.sh b/scripts/ci-verify.sh new file mode 100755 index 0000000..343ad18 --- /dev/null +++ b/scripts/ci-verify.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +pass_count=0 +fail_count=0 +start_time=$(date +%s) + +step() { + local label="$1" + shift + echo -e "${YELLOW}[CI]${NC} ${label}..." + if "$@" > /tmp/ci-output-$$.log 2>&1; then + echo -e " ${GREEN}PASS${NC}" + pass_count=$((pass_count + 1)) + else + echo -e " ${RED}FAIL${NC}" + tail -30 /tmp/ci-output-$$.log + fail_count=$((fail_count + 1)) + fi +} + +echo "============================================================" +echo " Imhotep CI Verification" +echo "============================================================" +echo "" + +# 1. Build all packages +step "Build" npm run build + +# 2. Typecheck +step "Typecheck" npm run typecheck + +# 3. Lint +step "Lint" npm run lint + +# 4. Structural conformance (as any, empty catch, ?? 0 baselines) +step "Structural conformance" node scripts/check-structural.js + +# 5. Unit tests (DSL, core, solver, playwright) +step "Core tests (DSL)" npm test --workspace=imhotep-dsl --if-present +step "Core tests (core)" npm test --workspace=imhotep-core --if-present +step "Core tests (solver)" npm test --workspace=imhotep-solver --if-present +step "Core tests (extractor)" npm test --workspace=imhotep-extractor --if-present +step "Core tests (geometry)" npm test --workspace=imhotep-geometry --if-present + +# 6. E2E tests (requires browser) +echo -e "${YELLOW}[CI]${NC} E2E hard tests..." +if npx playwright test --config packages/imhotep-fixtures/playwright.config.ts --grep="hard test" --workers=1 > /tmp/ci-output-$$.log 2>&1; then + echo -e " ${GREEN}PASS${NC}" + pass_count=$((pass_count + 1)) +else + echo -e " ${RED}FAIL${NC}" + tail -30 /tmp/ci-output-$$.log + fail_count=$((fail_count + 1)) +fi + +# Summary +end_time=$(date +%s) +elapsed=$((end_time - start_time)) + +echo "" +echo "============================================================" +echo " CI Summary" +echo "============================================================" +echo " Passed: ${GREEN}${pass_count}${NC}" +echo " Failed: ${RED}${fail_count}${NC}" +echo " Elapsed: ${elapsed}s" +echo "" + +if [ "$fail_count" -gt 0 ]; then + echo -e "${RED}CI verification FAILED${NC}" + exit 1 +else + echo -e "${GREEN}CI verification PASSED${NC}" + exit 0 +fi