diff --git a/quality/knip-baseline.txt b/quality/knip-baseline.txt new file mode 100644 index 00000000..9ce39970 --- /dev/null +++ b/quality/knip-baseline.txt @@ -0,0 +1,4 @@ +# Known Knip findings (currently none). +# Normalized as: :: +# Source line/column positions are intentionally excluded. +# Do not add new findings merely to make the quality gate pass. diff --git a/run-tests.sh b/run-tests.sh index 37a3ae8e..f90f2539 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -4,6 +4,17 @@ # UnoSim Test & Build Pipeline (Stability & Resource Guard) # ───────────────────────────────────────────────────────────────── +# Keep this explicit mode limited to the Knip gate; no argument preserves the +# full pipeline. Do not accept additional options in gate-only mode. +KNIP_GATE_ONLY=0 +if [[ "${1:-}" == "--knip-gate-only" ]]; then + if [ "$#" -ne 1 ]; then + echo "Usage: ./run-tests.sh [--knip-gate-only]" + exit 2 + fi + KNIP_GATE_ONLY=1 +fi + # Konfiguration LOG_FILE="run-tests_output.log" TOTAL_STEPS=11 @@ -58,7 +69,9 @@ cleanup() { fi fi } -trap cleanup EXIT +if [ "$KNIP_GATE_ONLY" -eq 0 ]; then + trap cleanup EXIT +fi run_task() { local label=$1 cmd=$2 note=${3:-} @@ -115,15 +128,21 @@ parse_test_results() { # ───────────────────────────────────────────────────────────────── # MAIN # ───────────────────────────────────────────────────────────────── -clear -div -printf " ${B}UnoSim Test & Build Pipeline${RS} ${D}(Log: %s)${RS}\n" "$LOG_FILE" -div -rm -f "$LOG_FILE" -[ -d temp ] && rm -rf temp/* +if [ "$KNIP_GATE_ONLY" -eq 0 ]; then + clear + div + printf " ${B}UnoSim Test & Build Pipeline${RS} ${D}(Log: %s)${RS}\n" "$LOG_FILE" + div + rm -f "$LOG_FILE" + [ -d temp ] && rm -rf temp/* +fi # ─────── PRE-FLIGHT ─────── -echo -e "\n${B}▸ [Pre-Flight] System checks & cleanup${RS}" +if [ "$KNIP_GATE_ONLY" -eq 1 ]; then + echo -e "\n${B}▸ [Pre-Flight] Node/npm for Knip${RS}" +else + echo -e "\n${B}▸ [Pre-Flight] System checks & cleanup${RS}" +fi # Node.js / npm if ! command -v npm &>/dev/null; then @@ -141,6 +160,12 @@ if [ -n "$EXPECTED_NODE" ] && [ "$ACTUAL_NODE" != "$EXPECTED_NODE" ]; then fi echo -e " ${OK} Node.js v${ACTUAL_NODE}" +if [ "$KNIP_GATE_ONLY" -eq 1 ] && ! command -v npx &>/dev/null; then + echo -e " ${FAIL} npx not found – please install Node.js/npm" + exit 1 +fi + +if [ "$KNIP_GATE_ONLY" -eq 0 ]; then # Docker DOCKER_AVAILABLE=0 if command -v docker &>/dev/null && docker info >/dev/null 2>&1; then @@ -192,19 +217,233 @@ fi # 1. Static analysis run_task "Static Analysis" "npm run check" +fi -# 2. Dead-code check (knip) — non-blocking, zeigt unused exports/types als Warnung +# 2. Dead-code check (knip) — known findings are tracked in a reviewed baseline STEP=$((STEP+1)) echo -e "\n${B}▸ [$STEP/$TOTAL_STEPS] Dead-Code Check (knip)${RS}" +KNIP_BASELINE_FILE="${KNIP_BASELINE_FILE:-quality/knip-baseline.txt}" +KNIP_TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/unosim-knip.XXXXXX") +if [ $? -ne 0 ] || [ -z "$KNIP_TMP_DIR" ] || [ ! -d "$KNIP_TMP_DIR" ]; then + echo -e " ${FAIL} Dead-Code Check FAIL: could not create scratch directory" + exit 1 +fi + +knip_gate_fail() { + echo -e " ${FAIL} Dead-Code Check FAIL: $1" + rm -rf "$KNIP_TMP_DIR" + exit 1 +} + +if [ ! -f "$KNIP_BASELINE_FILE" ] || [ ! -r "$KNIP_BASELINE_FILE" ]; then + knip_gate_fail "baseline missing or unreadable: $KNIP_BASELINE_FILE" +fi + +# Baseline records are data, never generated or updated by this pipeline. +if ! awk -v errors="$KNIP_TMP_DIR/baseline-errors" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + function valid_path(path) { + return path ~ /^([[:alnum:]_.-]+\/)+[[:alnum:]_.-]+$/ && path !~ /(^|\/)\.\.?($|\/)/ + } + function valid_record(category, detail, fields, field_count) { + field_count = split(detail, fields, " ") + if (field_count < 2 || !valid_path(fields[field_count])) return 0 + + if (category == "Unused exports") { + if (field_count != 2 && field_count != 3) return 0 + return field_count == 2 || fields[2] ~ /^[[:alpha:]_][[:alnum:]_-]*$/ + } + if (category == "Unused exported types") { + return field_count == 3 && fields[2] == "type" + } + if (category == "Duplicate exports") { + if (field_count != 2 || fields[1] !~ /^[^|]+\|[^|]+$/) return 0 + return 1 + } + return 0 + } + { + original = $0 + sub(/\r$/, "", original) + line = trim(original) + if (line == "" || line ~ /^#/) next + + normalized = line + gsub(/[[:space:]]+/, " ", normalized) + delimiter = index(line, " :: ") + category = substr(line, 1, delimiter - 1) + detail = substr(line, delimiter + 4) + if (delimiter < 2 || delimiter + 4 > length(line) || normalized != line || + line ~ /:[0-9]+:[0-9]+$/ || !valid_record(category, detail)) { + print NR ": " original >> errors + invalid = 1 + next + } + print line + records++ + } + END { + if (invalid) exit 1 + } +' "$KNIP_BASELINE_FILE" > "$KNIP_TMP_DIR/baseline.raw"; then + if [ -s "$KNIP_TMP_DIR/baseline-errors" ]; then + sed 's/^/ /' "$KNIP_TMP_DIR/baseline-errors" + fi + knip_gate_fail "invalid baseline: $KNIP_BASELINE_FILE" +fi + +LC_ALL=C sort -u "$KNIP_TMP_DIR/baseline.raw" > "$KNIP_TMP_DIR/baseline.sorted" +KNIP_BASELINE_COUNT=$(wc -l < "$KNIP_TMP_DIR/baseline.raw" | tr -d '[:space:]') +KNIP_BASELINE_UNIQUE_COUNT=$(wc -l < "$KNIP_TMP_DIR/baseline.sorted" | tr -d '[:space:]') +if [ "$KNIP_BASELINE_COUNT" -ne "$KNIP_BASELINE_UNIQUE_COUNT" ]; then + knip_gate_fail "duplicate finding records in baseline: $KNIP_BASELINE_FILE" +fi + KNIP_OUT=$(npx knip 2>&1) KNIP_EXIT=$? -if [ $KNIP_EXIT -eq 0 ]; then - echo -e " ${OK} Dead-Code Check no issues" -else - KNIP_LINES=$(echo "$KNIP_OUT" | wc -l | tr -d ' ') - echo -e " ${WARN} Dead-Code Check ${KNIP_LINES} line(s) – see $LOG_FILE" - echo "=== knip output ==" >> "$LOG_FILE" - echo "$KNIP_OUT" >> "$LOG_FILE" +if [ -n "$KNIP_OUT" ] || [ "$KNIP_EXIT" -ne 0 ]; then + printf '=== knip output (exit %s) ===\n' "$KNIP_EXIT" >> "$LOG_FILE" + if [ -n "$KNIP_OUT" ]; then + printf '%s\n' "$KNIP_OUT" >> "$LOG_FILE" + fi +fi + +# Convert each Knip section into a stable record, independent of line/column +# positions and terminal alignment. Unrecognized output fails closed. +: > "$KNIP_TMP_DIR/current.raw" +if ! awk -v records="$KNIP_TMP_DIR/current.raw" -v errors="$KNIP_TMP_DIR/parse-errors" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + function valid_path(path) { + return path ~ /^([[:alnum:]_.-]+\/)+[[:alnum:]_.-]+$/ && path !~ /(^|\/)\.\.?($|\/)/ + } + function valid_record(category, detail, fields, field_count) { + field_count = split(detail, fields, " ") + if (field_count < 2 || !valid_path(fields[field_count])) return 0 + + if (category == "Unused exports") { + if (field_count != 2 && field_count != 3) return 0 + return field_count == 2 || fields[2] ~ /^[[:alpha:]_][[:alnum:]_-]*$/ + } + if (category == "Unused exported types") { + return field_count == 3 && fields[2] == "type" + } + if (category == "Duplicate exports") { + if (field_count != 2 || fields[1] !~ /^[^|]+\|[^|]+$/) return 0 + return 1 + } + return 0 + } + function fail(message) { + print message >> errors + invalid = 1 + } + { + original = $0 + sub(/\r$/, "", original) + line = trim(original) + if (line == "") next + nonempty++ + + if (line ~ /^.+ \([0-9]+\)$/) { + category = line + sub(/ \([0-9]+\)$/, "", category) + count = line + sub(/^.* \(/, "", count) + sub(/\)$/, "", count) + if (category != "Unused exports" && category != "Unused exported types" && category != "Duplicate exports") { + fail("unrecognized section heading: " line) + } + if (category == "" || seen[category]++) { + fail("invalid or duplicate section heading: " line) + } + section = category + expected[category] = count + 0 + sections++ + next + } + + if (section == "") { + fail("unclassified Knip output: " line) + gsub(/[[:space:]]+/, " ", line) + print "__UNCLASSIFIED__ :: " line >> records + next + } + + finding = line + sub(/:[0-9]+:[0-9]+$/, "", finding) + finding = trim(finding) + gsub(/[[:space:]]+/, " ", finding) + if (!valid_record(section, finding)) { + fail("malformed finding in section " section ": " finding) + next + } + print section " :: " finding >> records + actual[section]++ + findings++ + } + END { + for (category in expected) { + if (actual[category] != expected[category]) { + fail("section " category " declared " expected[category] " finding(s), parsed " (actual[category] + 0)) + } + } + if (nonempty > 0 && sections == 0) { + fail("Knip output has no parseable section headings") + } + if (invalid) exit 1 + } +' <<< "$KNIP_OUT"; then + if [ -s "$KNIP_TMP_DIR/parse-errors" ]; then + sed 's/^/ /' "$KNIP_TMP_DIR/parse-errors" + fi + knip_gate_fail "Knip output was not fully interpretable; see $LOG_FILE" +fi + +LC_ALL=C sort -u "$KNIP_TMP_DIR/current.raw" > "$KNIP_TMP_DIR/current.sorted" +KNIP_CURRENT_COUNT=$(wc -l < "$KNIP_TMP_DIR/current.raw" | tr -d '[:space:]') +KNIP_CURRENT_UNIQUE_COUNT=$(wc -l < "$KNIP_TMP_DIR/current.sorted" | tr -d '[:space:]') +if [ "$KNIP_CURRENT_COUNT" -ne "$KNIP_CURRENT_UNIQUE_COUNT" ]; then + knip_gate_fail "duplicate normalized Knip findings; see $LOG_FILE" +fi +if { [ "$KNIP_EXIT" -eq 0 ] && [ "$KNIP_CURRENT_COUNT" -ne 0 ]; } || \ + { [ "$KNIP_EXIT" -ne 0 ] && [ "$KNIP_CURRENT_COUNT" -eq 0 ]; } || \ + [ "$KNIP_EXIT" -gt 1 ]; then + knip_gate_fail "Knip exited with status $KNIP_EXIT inconsistent with parsed findings; see $LOG_FILE" +fi + +LC_ALL=C comm -23 "$KNIP_TMP_DIR/current.sorted" "$KNIP_TMP_DIR/baseline.sorted" > "$KNIP_TMP_DIR/new" +LC_ALL=C comm -12 "$KNIP_TMP_DIR/current.sorted" "$KNIP_TMP_DIR/baseline.sorted" > "$KNIP_TMP_DIR/known" +LC_ALL=C comm -13 "$KNIP_TMP_DIR/current.sorted" "$KNIP_TMP_DIR/baseline.sorted" > "$KNIP_TMP_DIR/resolved" +KNIP_NEW_COUNT=$(wc -l < "$KNIP_TMP_DIR/new" | tr -d '[:space:]') +KNIP_KNOWN_COUNT=$(wc -l < "$KNIP_TMP_DIR/known" | tr -d '[:space:]') +KNIP_RESOLVED_COUNT=$(wc -l < "$KNIP_TMP_DIR/resolved" | tr -d '[:space:]') + +if [ "$KNIP_NEW_COUNT" -gt 0 ]; then + echo -e " ${FAIL} Dead-Code Check $KNIP_NEW_COUNT new finding(s)" + echo " Known baseline: $KNIP_KNOWN_COUNT" + echo " New findings:" + sed 's/^/ ✘ /' "$KNIP_TMP_DIR/new" + printf '=== normalized new knip findings ===\n' >> "$LOG_FILE" + cat "$KNIP_TMP_DIR/new" >> "$LOG_FILE" + rm -rf "$KNIP_TMP_DIR" + exit 1 +fi + +echo -e " ${OK} Dead-Code Check new: 0, baseline: $KNIP_KNOWN_COUNT, resolved: $KNIP_RESOLVED_COUNT" +if [ "$KNIP_RESOLVED_COUNT" -gt 0 ]; then + echo -e " ${OK} Baseline improved: $KNIP_RESOLVED_COUNT known finding(s) resolved" +fi +rm -rf "$KNIP_TMP_DIR" +if [ "$KNIP_GATE_ONLY" -eq 1 ]; then + exit 0 fi # 3. Unit tests and complete coverage report for the SonarQube scan diff --git a/tests/scripts/knip-baseline-gate.test.mjs b/tests/scripts/knip-baseline-gate.test.mjs new file mode 100644 index 00000000..d64d7b6a --- /dev/null +++ b/tests/scripts/knip-baseline-gate.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const expectedFinding = "Unused exports :: candidateName server/services/example.ts"; +const knipFindingOutput = "Unused exports (1)\n candidateName server/services/example.ts:1:1"; + +function runGate({ baselineContent, knipOutput = "", knipStatus = 0 }) { + const testRoot = mkdtempSync(join(tmpdir(), "unosim-knip-gate-test-")); + const fakeBin = join(testRoot, "bin"); + const baselinePath = join(testRoot, "knip-baseline.txt"); + const callsPath = join(testRoot, "calls.txt"); + const testScript = join(testRoot, "run-tests.sh"); + + mkdirSync(fakeBin); + copyFileSync(join(repositoryRoot, "run-tests.sh"), testScript); + copyFileSync(join(repositoryRoot, ".nvmrc"), join(testRoot, ".nvmrc")); + writeFileSync(join(testRoot, "check-leaks.sh"), '#!/bin/sh\nprintf "check-leaks\\n" >> "$KNIP_TEST_CALLS"\n'); + chmodSync(join(testRoot, "check-leaks.sh"), 0o755); + + if (baselineContent !== undefined) writeFileSync(baselinePath, baselineContent); + + const stubs = { + clear: 'printf "clear\\n" >> "$KNIP_TEST_CALLS"', + docker: 'printf "docker\\n" >> "$KNIP_TEST_CALLS"\nexit 1', + lsof: 'printf "lsof\\n" >> "$KNIP_TEST_CALLS"\nexit 1', + npm: 'printf "npm\\n" >> "$KNIP_TEST_CALLS"\nexit 0', + npx: [ + 'printf "npx\\n" >> "$KNIP_TEST_CALLS"', + '[ "$#" -eq 1 ] && [ "$1" = knip ] || exit 99', + 'printf "%s\\n" "$KNIP_STUB_OUTPUT"', + 'exit "$KNIP_STUB_STATUS"', + ].join("\n"), + }; + + for (const [name, body] of Object.entries(stubs)) { + const stubPath = join(fakeBin, name); + writeFileSync(stubPath, `#!/bin/sh\n${body}\n`); + chmodSync(stubPath, 0o755); + } + + const env = { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH}`, + KNIP_BASELINE_FILE: baselinePath, + KNIP_TEST_CALLS: callsPath, + KNIP_STUB_OUTPUT: knipOutput, + KNIP_STUB_STATUS: String(knipStatus), + SONAR_TOKEN: "", + }; + + try { + const result = spawnSync("bash", [testScript, "--knip-gate-only"], { + cwd: testRoot, + encoding: "utf8", + env, + timeout: 10_000, + }); + const calls = existsSync(callsPath) ? readFileSync(callsPath, "utf8").trim().split("\n") : []; + return { ...result, calls, testRoot }; + } catch (error) { + rmSync(testRoot, { recursive: true, force: true }); + throw error; + } +} + +test("Knip gate-only mode validates baselines and compares actual Knip output", async (t) => { + await t.test("A: comment-only baseline passes with zero counts", () => { + const result = runGate({ baselineContent: "# reviewed baseline\n\n# no findings\n" }); + try { + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /new: 0, baseline: 0, resolved: 0/); + assert.deepEqual(result.calls, ["npx"]); + } finally { + rmSync(result.testRoot, { recursive: true, force: true }); + } + }); + + await t.test("B: a Knip finding absent from an empty baseline fails", () => { + const result = runGate({ baselineContent: "# empty\n", knipOutput: knipFindingOutput, knipStatus: 1 }); + try { + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /1 new finding\(s\)/); + assert.ok(result.stdout.includes(expectedFinding)); + assert.deepEqual(result.calls, ["npx"]); + } finally { + rmSync(result.testRoot, { recursive: true, force: true }); + } + }); + + await t.test("C: a valid known finding remains an accepted baseline record", () => { + const result = runGate({ + baselineContent: `# one known finding\n${expectedFinding}\n`, + knipOutput: knipFindingOutput, + knipStatus: 1, + }); + try { + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /new: 0, baseline: 1, resolved: 0/); + assert.deepEqual(result.calls, ["npx"]); + } finally { + rmSync(result.testRoot, { recursive: true, force: true }); + } + }); + + await t.test("D: a malformed non-empty baseline fails before invoking Knip", () => { + const result = runGate({ baselineContent: "not a normalized Knip record\n" }); + try { + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /invalid baseline/); + assert.deepEqual(result.calls, []); + } finally { + rmSync(result.testRoot, { recursive: true, force: true }); + } + }); + + await t.test("E: a missing baseline fails before invoking Knip", () => { + const result = runGate({}); + try { + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /baseline missing or unreadable/); + assert.deepEqual(result.calls, []); + } finally { + rmSync(result.testRoot, { recursive: true, force: true }); + } + }); +});