diff --git a/.auto/baseline.env b/.auto/baseline.env new file mode 100644 index 00000000..71419500 --- /dev/null +++ b/.auto/baseline.env @@ -0,0 +1,12 @@ +# Autoresearch baseline — captured at iteration #0 (2026-08-29). +# The Guard compares live values against these floors; the PRIMARY metric is debt. +BASE_DEBT=102 +BASE_NOLINT=96 +BASE_TESTS_PASSED=46 +BASE_TEST_FUNCS=232 +BASE_TEST_FILES=76 +BASE_ARCH_VIOL=0 +BASE_COVERAGE=34.09 + +# SHA-256 of the pinned yardstick config. Guard aborts if it changes. +REF_SHA=e881bda167bd688489f1356b7cd4056b8a6960f48b6778b095bdd2e44f627b82 diff --git a/.auto/check_gate_weaken.py b/.auto/check_gate_weaken.py new file mode 100644 index 00000000..42467641 --- /dev/null +++ b/.auto/check_gate_weaken.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Anti-cheat: prove .golangci.yml was only ever strengthened, never weakened. + +Compares the live gate against the immutable snapshot taken at run start. +Exits non-zero with a reason if any hardening rule is violated. +""" + +import os +import sys + +import yaml + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASELINE = os.path.join(ROOT, ".auto", "gate.baseline.yml") +LIVE = os.path.join(ROOT, ".golangci.yml") + +# threshold-like knobs: (path, direction) where direction "max" means the value +# is an upper bound (smaller == stricter), "min" means a lower bound. +STRICTNESS = [ + (("linters", "settings", "dupl", "threshold"), "max"), + (("linters", "settings", "cyclop", "max-complexity"), "max"), + (("linters", "settings", "cyclop", "package-average"), "max"), + (("linters", "settings", "nestif", "min-complexity"), "min"), + (("linters", "settings", "funlen", "lines"), "max"), + (("linters", "settings", "funlen", "statements"), "max"), + (("linters", "settings", "gocyclo", "min-complexity"), "min"), + (("linters", "settings", "lll", "line-length"), "max"), +] + + +def load(path): + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def dig(doc, path): + node = doc + for key in path: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + return node + + +def enabled_linters(doc): + lint = doc.get("linters") or {} + if lint.get("enable-presets"): + return None # preset based; fall back to "any removal is suspicious" + return set(lint.get("enable") or []) + + +def main(): + try: + base, live = load(BASELINE), load(LIVE) + except OSError as exc: + print(f"gate snapshot unreadable: {exc}") + return 1 + except yaml.YAMLError as exc: + print(f".golangci.yml is not parseable: {exc}") + return 1 + + problems = [] + + base_lint, live_lint = base.get("linters") or {}, live.get("linters") or {} + if (base_lint.get("default") or "none") != (live_lint.get("default") or "none"): + problems.append("linters.default changed") + + base_set, live_set = enabled_linters(base), enabled_linters(live) + if base_set is None or live_set is None: + if set((base.get("linters") or {}).get("enable-presets") or []) - set( + (live.get("linters") or {}).get("enable-presets") or [] + ): + problems.append("an enable-preset was removed") + elif dropped := base_set - live_set: + problems.append(f"linters disabled: {sorted(dropped)}") + + for path, direction in STRICTNESS: + old, new = dig(base, path), dig(live, path) + if old is None or new is None: + continue + try: + old_f, new_f = float(old), float(new) + except (TypeError, ValueError): + continue + if direction == "max" and new_f > old_f: + problems.append(f"{'.'.join(path)} loosened {old} -> {new}") + if direction == "min" and new_f < old_f: + problems.append(f"{'.'.join(path)} loosened {old} -> {new}") + + base_mnd = set(dig(base, ("linters", "settings", "mnd", "checks")) or []) + live_mnd = set(dig(live, ("linters", "settings", "mnd", "checks")) or []) + if base_mnd - live_mnd: + problems.append(f"mnd checks dropped: {sorted(base_mnd - live_mnd)}") + + issues_live = live.get("issues") or {} + for key in ("exclude-rules", "exclude-patterns"): + if issues_live.get(key) and not (base.get("issues") or {}).get(key): + problems.append(f"issues.{key} added (suppresses reporting)") + + for key in ("max-issues-per-linter", "max-same-issues"): + old = (base.get("issues") or {}).get(key) + new = issues_live.get(key) + if old == 0 and new != 0: + problems.append(f"issues.{key} no longer 0 — findings would be truncated") + + # Exclusions expressed through the newer 'linters.exclusions' block. + if (live_lint.get("exclusions") or {}) and not (base_lint.get("exclusions") or {}): + problems.append("linters.exclusions added") + + if problems: + print("\n".join(f" - {p}" for p in problems)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.auto/checks.sh b/.auto/checks.sh index 72631f79..c4bad42a 100755 --- a/.auto/checks.sh +++ b/.auto/checks.sh @@ -1,11 +1,50 @@ #!/bin/bash -# Correctness gate: build + full tests + Cordis architecture checker (errors only) -set -euo pipefail -cd "$(dirname "$0")/../backend" +# Autoresearch GUARD — hard veto. Every line here protects an invariant that is +# unrelated to the primary metric, plus the anti-cheat red lines. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${ROOT}/backend" +STATUS=0 +fail() { echo "GUARD FAIL: $1"; STATUS=1; } -go build ./... 2>&1 | head -20 -go test ./... 2>&1 | grep -vE '^(ok|---|\?|PASS)' | grep -v 'no test files' | head -40 || true -FAILS=$(go test ./... 2>&1 | grep -cE '^(FAIL|--- FAIL)' || true) -if [ "${FAILS}" != "0" ]; then echo "TESTS FAILED (${FAILS})"; exit 1; fi -"$(dirname "$0")/../scripts/check_cordis_architecture.sh" >/dev/null 2>&1 || { echo "CORDIS ARCH CHECK FAILED"; exit 1; } -echo "CHECKS OK" +source "${ROOT}/.auto/baseline.env" + +# --- 1. Correctness ----------------------------------------------------------- +go build ./... || fail "go build failed" +go vet ./... || fail "go vet failed" + +go test ./... > /tmp/ar_guard_test.txt 2>&1 || true +FAILS=$(grep -cE '^(FAIL|--- FAIL)' /tmp/ar_guard_test.txt || true) +[ "${FAILS}" = "0" ] || { grep -E '^(FAIL|--- FAIL)' /tmp/ar_guard_test.txt | head -20; fail "tests failing (${FAILS})"; } + +# --- 2. Cordis architecture gate --------------------------------------------- +"${ROOT}/scripts/check_cordis_architecture.sh" > /dev/null 2>&1 || fail "cordis architecture check failed" + +# --- 3. Project lint gate must stay clean ------------------------------------ +PROJECT_LINT=$(golangci-lint run 2>&1 | grep -cE '\.go:[0-9]+:[0-9]+: ' || true) +[ "${PROJECT_LINT}" = "0" ] || { fail "project golangci-lint reports ${PROJECT_LINT} issues"; } + +# --- 4. Anti-cheat: the yardstick itself is immutable ------------------------ +REF_SHA_NOW=$(shasum -a 256 "${ROOT}/.auto/lint.ref.yaml" | awk '{print $1}') +[ "${REF_SHA_NOW}" = "${REF_SHA}" ] || fail "pinned yardstick .auto/lint.ref.yaml was modified" + +# --- 5. Anti-cheat: the project gate may only ever be STRENGTHENED ------------ +WEAK=$(python3 "${ROOT}/.auto/check_gate_weaken.py" 2>&1) || { echo "${WEAK}"; fail "project gate weakened"; } + +# --- 6. Anti-cheat: no new suppressions -------------------------------------- +NOLINT=$(rg '//\s*nolint' --glob '*.go' 2>/dev/null | wc -l | tr -d ' ') +[ "${NOLINT}" -le "${BASE_NOLINT}" ] || fail "nolint directives grew (${NOLINT} > ${BASE_NOLINT})" + +# --- 7. Anti-cheat: no tests deleted, no packages lost ----------------------- +TEST_FUNCS=$(rg -c '^(func Test|func Benchmark)' --glob '*_test.go' 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') +[ "${TEST_FUNCS}" -ge "${BASE_TEST_FUNCS}" ] || fail "test funcs shrank (${TEST_FUNCS} < ${BASE_TEST_FUNCS})" +TEST_FILES=$(rg --files --glob '*_test.go' 2>/dev/null | wc -l | tr -d ' ') +[ "${TEST_FILES}" -ge "${BASE_TEST_FILES}" ] || fail "test files deleted (${TEST_FILES} < ${BASE_TEST_FILES})" +PASSED=$(grep -c '^ok' /tmp/ar_guard_test.txt || true) +[ "${PASSED}" -ge "${BASE_TESTS_PASSED}" ] || fail "passing packages shrank (${PASSED} < ${BASE_TESTS_PASSED})" + +# --- 8. License headers on Go sources (CI gate) ------------------------------ +"${ROOT}/scripts/update_go_license.sh" --check > /dev/null 2>&1 || fail "license header check failed" + +if [ "${STATUS}" = "0" ]; then echo "CHECKS OK"; fi +exit ${STATUS} diff --git a/.auto/gate.baseline.yml b/.auto/gate.baseline.yml new file mode 100644 index 00000000..adb951c1 --- /dev/null +++ b/.auto/gate.baseline.yml @@ -0,0 +1,61 @@ +version: "2" + +run: + timeout: 5m + tests: false + +linters: + default: none + enable: + # 基础检查 + - govet + - staticcheck + - errcheck + - ineffassign + - unused + + # 代码坏味道 + - dupl # 重复代码 + - mnd # 魔法数字 + - goconst # 不必要的字符串常量 + - cyclop # 包/函数复杂度 + - nestif # if 嵌套太深 + - maintidx # 维护性指数 + - revive # 风格/命名/坏味道 + - gocritic # 各类代码问题 + - funlen # 函数过长 + + - gosec # 安全问题检查 + - bodyclose # HTTP response body 没有正确关闭 + - noctx # 没有传递 context.Context + - contextcheck # 其他检查 + - sqlclosecheck # SQL rows 没有正确关闭 + - unconvert # 不必要的类型转换 + - nilerr # 函数返回 nil 错误 + + settings: + dupl: + threshold: 80 + + cyclop: + max-complexity: 20 + package-average: 10 + + nestif: + min-complexity: 5 + + funlen: + lines: 200 + statements: 100 + + mnd: + checks: + - argument + - condition + - return + + +# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实) +issues: + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/.auto/lint.ref.yaml b/.auto/lint.ref.yaml new file mode 100644 index 00000000..b60c74b1 --- /dev/null +++ b/.auto/lint.ref.yaml @@ -0,0 +1,78 @@ +version: "2" + +# Pinned autoresearch yardstick. IMMUTABLE for the duration of a run. +# Snapshot of the committed .golangci.yml plus the extra analyzers that report +# genuine defects (correctness / panics / dead code) rather than cosmetics. +# Keeping this separate from .golangci.yml means strengthening the project gate +# can never silently lower the measured debt. + +run: + timeout: 5m + tests: false + +linters: + default: none + enable: + # --- from the committed project gate --- + - govet + - staticcheck + - errcheck + - ineffassign + - unused + - dupl + - mnd + - goconst + - cyclop + - nestif + - maintidx + - revive + - gocritic + - funlen + - gosec + - bodyclose + - noctx + - contextcheck + - sqlclosecheck + - unconvert + - nilerr + # --- extra real-risk analyzers (defects, not cosmetics) --- + - errorlint # err == / %v instead of errors.Is/As and %w + - forcetypeassert # unchecked type assertions can panic + - nilnil # (value, nil) breaks the nil-check contract + - predeclared # shadowing builtins + - unparam # dead params/results + - wastedassign # dead stores + - exhaustive # enum switches missing cases + - makezero # append to preallocated slice + - rowserrcheck # sql.Rows error after iteration + - durationcheck # multiplied time.Duration + - prealloc # slice growth in loops + - copyloopvar # loop-var capture + - nonamedreturns + - funcorder # struct methods scattered across files + - nolintlint # suppression audit (must stay 0) + + settings: + dupl: + threshold: 80 + + cyclop: + max-complexity: 20 + package-average: 10 + + nestif: + min-complexity: 5 + + funlen: + lines: 200 + statements: 100 + + mnd: + checks: + - argument + - condition + - return + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.auto/measure.sh b/.auto/measure.sh index ccf47e4d..cb752fc3 100755 --- a/.auto/measure.sh +++ b/.auto/measure.sh @@ -1,18 +1,38 @@ #!/bin/bash -# Autoresearch measure: golangci-lint issue count (primary) + dupl subset + test packages -set -euo pipefail +# Autoresearch measure — pinned yardstick. +# debt : findings under .auto/lint.ref.yaml (lower is better) [PRIMARY] +# nolint_dirs : raw //nolint directive count (lower is better, floor 0) +# tests_passed : go test packages passing (floor, must never drop) +# test_funcs : total Test*/Benchmark* funcs (floor, must never drop) +# arch_viol : Cordis architecture script violations (floor 0) +# coverage : backend statement coverage % (informational) +set -uo pipefail cd "$(dirname "$0")/../backend" -# Primary metric: total golangci-lint issues (non-test code) -golangci-lint run > /tmp/ar_lint.txt 2>&1 || true -LINT_ISSUES=$(grep -cE '(^|[/\\])[^/\\:]+\.go:[0-9]+:' /tmp/ar_lint.txt || true) +REF_CFG="$(cd .. && pwd)/.auto/lint.ref.yaml" -# Secondary: dupl-specific issues -DUP_ISSUES=$(grep -cE '\(dupl\)$' /tmp/ar_lint.txt || true) +# Primary: pinned yardstick findings (never the mutable project config). +golangci-lint run -c "${REF_CFG}" > /tmp/ar_debt.txt 2>&1 || true +DEBT=$(grep -cE '\.go:[0-9]+:[0-9]+: ' /tmp/ar_debt.txt || true) -# Secondary: passing test packages (regression guard) -TESTS_PASSED=$(go test ./... 2>&1 | grep -c '^ok' || true) +# Suppression reliance — anti-cheat signal. +NOLINT=$(rg '//\s*nolint' --glob '*.go' 2>/dev/null | wc -l | tr -d ' ') -echo "METRIC lint_issues=${LINT_ISSUES}" -echo "METRIC dup_issues=${DUP_ISSUES}" +# Regression floors. One covered run feeds both the pass floor and coverage. +go test -cover ./... > /tmp/ar_test.txt 2>&1 || true +TESTS_PASSED=$(grep -c '^ok' /tmp/ar_test.txt || true) +FAILS=$(grep -cE '^(FAIL|--- FAIL)' /tmp/ar_test.txt || true) +TEST_FUNCS=$(rg -c '^(func Test|func Benchmark)' --glob '*_test.go' 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') + +# Cordis architecture violations (count of FAIL lines emitted by the gate script). +ARCH_VIOL=$("../scripts/check_cordis_architecture.sh" 2>&1 | grep -c 'FAIL' || true) + +COVERAGE=$(grep -oE 'coverage: [0-9.]+%' /tmp/ar_test.txt | awk '{gsub("%","",$2); s+=$2; n++} END {if(n>0) printf "%.2f", s/n; else print "0"}') + +echo "METRIC debt=${DEBT}" +echo "METRIC nolint_dirs=${NOLINT}" echo "METRIC tests_passed=${TESTS_PASSED}" +echo "METRIC test_funcs=${TEST_FUNCS}" +echo "METRIC arch_viol=${ARCH_VIOL}" +echo "METRIC coverage=${COVERAGE}" +echo "INFO test_failures=${FAILS}" diff --git a/.auto/prove_fix.sh b/.auto/prove_fix.sh new file mode 100755 index 00000000..7e20d716 --- /dev/null +++ b/.auto/prove_fix.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Mechanically prove a FIX iteration is load-bearing. +# +# Usage: .auto/prove_fix.sh [...] +# +# Run immediately AFTER committing the fix, with a clean worktree. It reverts +# only the non-test source files to their pre-fix state (keeping the new test), +# runs the package tests, and requires them to FAIL. Then it restores HEAD. +# A fix nobody can break with a revert is not a fix. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [ ! -z "$(git -C "${ROOT}" status --porcelain)" ]; then + echo "PROVE ABORT: worktree must be clean (commit the change first)" + exit 2 +fi + +PKG="$1"; shift +SRC_FILES=("$@") +if [ "${#SRC_FILES[@]}" -eq 0 ]; then + echo "PROVE ABORT: no source files given" + exit 2 +fi + +cd "${ROOT}/backend" || exit 2 + +restore() { + git -C "${ROOT}" checkout HEAD -- "${SRC_FILES[@]}" 2>/dev/null +} +trap restore EXIT + +for f in "${SRC_FILES[@]}"; do + if git -C "${ROOT}" cat-file -e "HEAD^:${f}" 2>/dev/null; then + git -C "${ROOT}" checkout "HEAD^" -- "${f}" || { echo "PROVE ABORT: cannot revert ${f}"; exit 2; } + else + # File did not exist before this commit — removing it is the revert. + rm -f "${ROOT}/${f}" + fi +done + +echo "--- tests against pre-fix source ---" +OUT=$(go test -count=1 "${PKG}" 2>&1) +RC=$? +echo "${OUT}" | tail -15 +if [ "${RC}" -eq 0 ]; then + echo "PROVE FAILED: tests still pass without the fix — this is not a real bug fix" + exit 1 +fi +if echo "${OUT}" | grep -qE '^(FAIL|--- FAIL)'; then + KIND="assertion" +else + KIND="compile" +fi +echo "PROVED: test fails without the fix (${KIND})" +exit 0 diff --git a/.auto/results.tsv b/.auto/results.tsv new file mode 100644 index 00000000..cfc3f7ef --- /dev/null +++ b/.auto/results.tsv @@ -0,0 +1,2 @@ +iteration commit metric delta status guard description +0 - 102 0.0 baseline pass initial measurement (pinned yardstick: repo gate + real-risk analyzers)