feat: CI verification pipeline and structural conformance gates
- Add scripts/check-structural.js: counts production as any, empty catch, and ?? 0 patterns; fails CI if counts exceed committed baseline - Add scripts/.structural-baseline.json: committed baseline (101 as any, 17 empty catch, 62 ?? 0); use --fix-baseline to ratchet down - Add scripts/ci-verify.sh: ordered pipeline (build → typecheck → lint → structural → unit tests → E2E) with pass/fail summary - Add npm scripts: test:structural, test:structural:fix, ci:verify - Add cache-staleness conformance test: verifies WORLD_CACHE_SCHEMA_VERSION is prefixed to all cache keys for auto-invalidation on schema changes - Cache test suite grows from 141 → 142 passes
This commit is contained in:
@@ -13,6 +13,9 @@
|
|||||||
"test:integration": "npm run test:integration -w imhotep-playwright",
|
"test:integration": "npm run test:integration -w imhotep-playwright",
|
||||||
"test:e2e": "npm run test:e2e -w imhotep-fixtures",
|
"test:e2e": "npm run test:e2e -w imhotep-fixtures",
|
||||||
"test:external-smoke": "node scripts/external-smoke.mjs",
|
"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",
|
"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"
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
clearGeometryCache,
|
clearGeometryCache,
|
||||||
readCachedExtractionResult,
|
readCachedExtractionResult,
|
||||||
writeCachedExtractionResult,
|
writeCachedExtractionResult,
|
||||||
|
WORLD_CACHE_SCHEMA_VERSION,
|
||||||
} from './geometry-cache.js'
|
} from './geometry-cache.js'
|
||||||
import { createEmptyWorld } from './world.js'
|
import { createEmptyWorld } from './world.js'
|
||||||
import type { GeometryWorld } from './world.js'
|
import type { GeometryWorld } from './world.js'
|
||||||
@@ -319,4 +320,10 @@ describe('geometry cache', () => {
|
|||||||
rmSync(cacheDir, { recursive: true, force: true })
|
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}`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"asAny": 101,
|
||||||
|
"emptyCatch": 17,
|
||||||
|
"nullishZero": 62
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
Executable
+84
@@ -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
|
||||||
Reference in New Issue
Block a user