diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2dafec6c89..2ae73d9711 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -194,6 +194,25 @@ jobs: "$HOME/.local/bin/osv-scanner" --version || true "$HOME/.local/bin/oasdiff" --version || true zizmor --version || true + - name: Forgotten sibling tests (advisory) + env: + GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + node scripts/quality/build-test-impact-map.mjs + node scripts/check/check-forgotten-sibling-tests.mjs \ + --summary-file forgotten-sibling-tests.md \ + --json-file forgotten-sibling-tests.json + cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY" + - name: Upload forgotten sibling report + if: always() + uses: actions/upload-artifact@v7 + with: + name: forgotten-sibling-tests + path: | + forgotten-sibling-tests.md + forgotten-sibling-tests.json + if-no-files-found: ignore + retention-days: 30 # Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps, # 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation # step. Each gate runs in a loop with ::group::; failures are collected and @@ -279,7 +298,8 @@ jobs: GITHUB_BASE_REF: ${{ github.base_ref }} run: | git fetch --no-tags origin "$GITHUB_BASE_REF" || true - node scripts/quality/build-test-impact-map.mjs + # The advisory sibling-test step generates the same map earlier in this job. + [ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs SEL="$(node scripts/quality/select-impacted-tests.mjs)" # Shadow evidence (#8084): persist every selection so TIA false negatives can # be measured against fast-unit's full-suite verdict across releases BEFORE diff --git a/changelog.d/features/9530-forgotten-sibling-tests-gate.md b/changelog.d/features/9530-forgotten-sibling-tests-gate.md new file mode 100644 index 0000000000..c9407e0e68 --- /dev/null +++ b/changelog.d/features/9530-forgotten-sibling-tests-gate.md @@ -0,0 +1 @@ +- Add an advisory forgotten-sibling-tests report to pull-request quality checks. The report traces changed modules through their static consumers to candidate sibling tests, while keeping barrel and dynamic-import cases non-blocking and requiring reviewed, referenced exceptions. diff --git a/config/quality/forgotten-sibling-allowlist.json b/config/quality/forgotten-sibling-allowlist.json new file mode 100644 index 0000000000..7f19696e7b --- /dev/null +++ b/config/quality/forgotten-sibling-allowlist.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": [] +} diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 00822ede4d..34c05d0699 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -29,11 +29,28 @@ changes: | `Build (advisory)` | Non-draft code PRs and Mergify queue branches; Node 24, `npm-ci-retry`, `check:node-runtime`, `npm run build` with `OMNIROUTE_USE_TURBOPACK=1`; no artifact upload because no downstream quality job consumes it | **Advisory** (`continue-on-error: true`; remove after one week of stable release-PR runs) | | `Docs Gates (fast-path)` | Docs/code PRs; API docs refs and docs-all | Yes | | `Fast Quality Gates` | Code PRs; static checks, typecheck, dashboard typecheck, impacted unit tests | Yes | +| `Forgotten sibling tests` | Code PRs; changed modules traced to static consumers and candidate sibling tests; barrel and dynamic-import paths are reported as advisory diagnostics, with referenced allowlist exceptions | **Advisory** | | `Vitest (fast-path)` | Code PRs; fast vitest suite | Yes | | `Unit Tests fast-path` | Code PRs; 4-shard unit suite | Yes | | `No new ESLint warnings` | Code PRs; suppressions-aware lint guard | Yes for own-origin, advisory for forks | | `Merge integrity (changelog + generated skills)` | Non-draft PRs; changelog and generated skill sync | Yes for own-origin, advisory for forks | +#### Forgotten sibling tests report + +`npm run check:forgotten-sibling-tests` reuses the import resolver behind the test-impact map. +For every changed production module, it reports deterministic +`changed module/symbol -> static consumer -> candidate sibling test` chains when the candidate +test is absent from the pull-request diff. The Markdown summary and JSON result are retained as +the `forgotten-sibling-tests` workflow artifact for calibration before any blocking rollout. + +Barrel re-exports and dynamic imports are resolution diagnostics only; they never create a +blocking finding. Reviewed exceptions live in +`config/quality/forgotten-sibling-allowlist.json`. Each entry must name the consumer and candidate +test, give a specific rationale, and link a GitHub issue or pull request. Malformed entries fail +closed. Exceptions cannot suppress a deleted candidate test or a diff that adds `.skip`/`.todo`; +assertion weakening and other masking remain owned by the independently blocking +`check:test-masking` gate. + ### Job: `lint` Runs on every PR to `main`. Blocks merge on failure. diff --git a/package.json b/package.json index c56c51c9ad..1cfcd8ae62 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "check:known-symbols": "bun scripts/check/check-known-symbols.ts", "check:route-guard-membership": "node --import tsx scripts/check/check-route-guard-membership.ts", "check:test-discovery": "node scripts/check/check-test-discovery.mjs", + "check:forgotten-sibling-tests": "node scripts/check/check-forgotten-sibling-tests.mjs", "check:mutation-test-coverage": "node scripts/check/check-mutation-test-coverage.mjs --strict", "check:complexity": "node scripts/check/check-complexity.mjs", "check:dead-code": "node scripts/check/check-dead-code.mjs", diff --git a/scripts/check/check-forgotten-sibling-tests.mjs b/scripts/check/check-forgotten-sibling-tests.mjs new file mode 100644 index 0000000000..bc9dd7ba4d --- /dev/null +++ b/scripts/check/check-forgotten-sibling-tests.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "tinyglobby"; + +import { resolveImport } from "../quality/build-test-impact-map.mjs"; + +const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"]; +const SOURCE_GLOBS = [ + "src/**/*.{ts,tsx,mts,js,mjs}", + "open-sse/**/*.{ts,tsx,mts,js,mjs}", + "bin/**/*.{ts,tsx,mts,js,mjs}", +]; +const IGNORE = [ + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "**/fixtures/**", + "**/generated/**", +]; +const STATIC_IMPORT_RE = + /(?:import|export)[^'"()]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g; +const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g; +const TEST_MASK_RE = + /^\+.*(?:\b(?:it|test|describe)\.(?:skip|todo)\b|\b(?:xit|xtest|xdescribe)\s*\()/; +const REFERENCE_RE = /^(?:#\d+|https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/\d+)$/; + +function normalize(file) { + return file.split(path.sep).join("/"); +} + +function isProduction(file) { + return ( + SOURCE_ROOTS.some((root) => file.startsWith(root)) && + !IGNORE.some((pattern) => { + const token = pattern.replaceAll("**/", "").replaceAll("/**", "").replaceAll("*", ""); + return token && file.includes(token); + }) + ); +} + +function isBarrel(file, code) { + return /(?:^|\/)index\.[cm]?[jt]sx?$/.test(file) && /\bexport\s+(?:\*|\{)/.test(code); +} + +function importEdges(root) { + const edges = []; + const files = globSync(SOURCE_GLOBS, { cwd: root, absolute: true, ignore: IGNORE }); + for (const absolute of files) { + const consumer = normalize(path.relative(root, absolute)); + const code = fs.readFileSync(absolute, "utf8"); + for (const match of code.matchAll(STATIC_IMPORT_RE)) { + const resolved = resolveImport(match[1] || match[2], absolute, root); + if (resolved) { + edges.push({ + module: normalize(path.relative(root, resolved)), + consumer, + kind: isBarrel(consumer, code) ? "barrel" : "static", + }); + } + } + for (const match of code.matchAll(DYNAMIC_IMPORT_RE)) { + const resolved = resolveImport(match[1], absolute, root); + if (resolved) { + edges.push({ + module: normalize(path.relative(root, resolved)), + consumer, + kind: "dynamic-import", + }); + } + } + } + return edges.sort((a, b) => + `${a.module}\0${a.consumer}\0${a.kind}`.localeCompare(`${b.module}\0${b.consumer}\0${b.kind}`) + ); +} + +export function validateAllowlist(value) { + const entries = Array.isArray(value) ? value : value?.entries; + if (!Array.isArray(entries)) + throw new Error("forgotten-sibling allowlist must contain an entries array"); + return entries.map((entry, index) => { + for (const field of ["consumer", "candidateTest", "rationale", "reference"]) { + if (typeof entry?.[field] !== "string" || !entry[field].trim()) { + throw new Error(`forgotten-sibling allowlist entry ${index} requires ${field}`); + } + } + if (entry.rationale.trim().length < 20) { + throw new Error(`forgotten-sibling allowlist entry ${index} rationale must be specific`); + } + if (!REFERENCE_RE.test(entry.reference.trim())) { + throw new Error( + `forgotten-sibling allowlist entry ${index} reference must be a GitHub issue or PR` + ); + } + return { + consumer: normalize(entry.consumer.trim()), + candidateTest: normalize(entry.candidateTest.trim()), + rationale: entry.rationale.trim(), + reference: entry.reference.trim(), + }; + }); +} + +export function analyzeForgottenSiblingTests({ + root = DEFAULT_ROOT, + changedEntries, + impactMap, + allowlist, + changedSymbolsByFile = {}, + addedTestLines = [], +}) { + const changed = new Map(changedEntries.map((entry) => [normalize(entry.file), entry.status])); + const changedModules = [...changed.keys()].filter(isProduction).sort(); + const maskingAdded = addedTestLines.some((line) => TEST_MASK_RE.test(line)); + const allow = new Map( + allowlist.map((entry) => [`${entry.consumer}\0${entry.candidateTest}`, entry]) + ); + const findings = []; + const diagnostics = []; + const suppressed = []; + const maskingRisks = []; + + for (const edge of importEdges(root)) { + if (!changedModules.includes(edge.module)) continue; + const tests = [...new Set(impactMap.sources?.[edge.consumer] || [])].sort(); + if (edge.kind !== "static") { + diagnostics.push({ + changedModule: edge.module, + consumer: edge.consumer, + kind: edge.kind, + message: `${edge.kind} resolution is advisory and never blocks`, + }); + continue; + } + for (const candidateTest of tests) { + const status = changed.get(candidateTest); + const masking = status === "D" || (status && maskingAdded); + if (masking) { + maskingRisks.push({ + changedModule: edge.module, + consumer: edge.consumer, + candidateTest, + reason: + status === "D" + ? "candidate sibling test was deleted" + : "candidate sibling test adds skip/todo masking", + }); + continue; + } + if (status) continue; + const finding = { + changedModule: edge.module, + changedSymbols: [...(changedSymbolsByFile[edge.module] || [])].sort(), + consumer: edge.consumer, + candidateTest, + reason: "candidate sibling test is absent from the PR diff", + }; + const exception = allow.get(`${edge.consumer}\0${candidateTest}`); + if (exception) suppressed.push({ ...finding, exception }); + else findings.push(finding); + } + } + return { mode: "advisory", findings, diagnostics, suppressed, maskingRisks }; +} + +function arg(name, fallback = "") { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback; +} + +function git(root, args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }); +} + +function changedEntries(root, base) { + return git(root, ["diff", "--name-status", "--diff-filter=ACMRD", `${base}...HEAD`]) + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const [status, ...files] = line.split("\t"); + return { status: status[0], file: files.at(-1) }; + }); +} + +function changedSymbols(root, base, entries) { + const result = {}; + const declaration = + /^\+\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/; + for (const entry of entries.filter(({ file }) => isProduction(file))) { + const diff = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", entry.file]); + result[entry.file] = [ + ...new Set( + diff + .split(/\r?\n/) + .map((line) => line.match(declaration)?.[1]) + .filter(Boolean) + ), + ]; + } + return result; +} + +function markdown(result, base) { + const lines = [ + "## Forgotten sibling tests (advisory)", + "", + `Base: \`${base}\``, + `Unallowlisted findings: ${result.findings.length}`, + `Reviewed exceptions: ${result.suppressed.length}`, + `Resolution diagnostics: ${result.diagnostics.length}`, + `Masking/deletion risks (owned by blocking sibling gates): ${result.maskingRisks.length}`, + "", + ]; + if (result.findings.length) { + lines.push("### Candidate tests absent from this diff", ""); + for (const item of result.findings) { + const symbol = item.changedSymbols.length ? ` (${item.changedSymbols.join(", ")})` : ""; + lines.push( + `- \`${item.changedModule}\`${symbol} -> \`${item.consumer}\` -> \`${item.candidateTest}\`` + ); + } + lines.push("", "> Report-only calibration: these findings do not fail the job.", ""); + } + for (const [heading, items] of [ + ["Resolution diagnostics", result.diagnostics], + ["Test masking/deletion risks", result.maskingRisks], + ]) { + if (!items.length) continue; + lines.push(`### ${heading}`, ""); + for (const item of items) + lines.push( + `- \`${item.changedModule}\` -> \`${item.consumer}\`${item.candidateTest ? ` -> \`${item.candidateTest}\`` : ""}: ${item.reason || item.message}` + ); + lines.push(""); + } + return `${lines.join("\n")}\n`; +} + +function main() { + const root = DEFAULT_ROOT; + const base = arg( + "--base", + process.env.GITHUB_BASE_SHA || + (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "HEAD~1") + ); + const mapPath = arg("--impact-map", path.join(root, "config/quality/test-impact-map.json")); + const allowlistPath = arg( + "--allowlist", + path.join(root, "config/quality/forgotten-sibling-allowlist.json") + ); + const summaryPath = arg("--summary-file", ""); + const jsonPath = arg("--json-file", ""); + const entries = changedEntries(root, base); + const impactMap = JSON.parse(fs.readFileSync(mapPath, "utf8")); + const allowlist = validateAllowlist(JSON.parse(fs.readFileSync(allowlistPath, "utf8"))); + const addedTestLines = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", "tests/"]) + .split(/\r?\n/) + .filter((line) => line.startsWith("+") && !line.startsWith("+++")); + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: entries, + impactMap, + allowlist, + changedSymbolsByFile: changedSymbols(root, base, entries), + addedTestLines, + }); + const report = markdown(result, base); + process.stdout.write(report); + for (const [target, contents] of [ + [summaryPath, report], + [jsonPath, `${JSON.stringify(result, null, 2)}\n`], + ]) { + if (!target) continue; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) { + try { + main(); + } catch (error) { + console.error( + `forgotten-sibling-tests: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } +} diff --git a/scripts/quality/build-test-impact-map.mjs b/scripts/quality/build-test-impact-map.mjs index 0120f95dc6..cdc1c91fed 100644 --- a/scripts/quality/build-test-impact-map.mjs +++ b/scripts/quality/build-test-impact-map.mjs @@ -9,11 +9,11 @@ const IMPORT_RE = /(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g; const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"]; -function resolveImport(spec, fromFile) { +export function resolveImport(spec, fromFile, root = ROOT) { let base; - if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2)); + if (spec.startsWith("@/")) base = path.join(root, "src", spec.slice(2)); else if (spec.startsWith("@omniroute/open-sse")) - base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, "")); + base = path.join(root, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, "")); else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec); else return null; for (const e of EXTS) { @@ -26,7 +26,7 @@ function resolveImport(spec, fromFile) { return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null; } -function sourceDepsOf(entry) { +export function sourceDepsOf(entry, root = ROOT) { const seen = new Set(); const stack = [entry]; const sources = new Set(); @@ -43,9 +43,9 @@ function sourceDepsOf(entry) { for (const m of code.matchAll(IMPORT_RE)) { const spec = m[1] || m[2] || m[3]; if (!spec) continue; - const r = resolveImport(spec, f); + const r = resolveImport(spec, f, root); if (!r) continue; - const rel = path.relative(ROOT, r); + const rel = path.relative(root, r); if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel); stack.push(r); } @@ -59,27 +59,35 @@ function sourceDepsOf(entry) { // e2e/integration tests, which can't run under node:test (they 99-false-failed before). // Mirror EXACTLY the package.json `test:unit` / `test:unit:ci` globs (incl. memory, // usage, combo, dashboard, serial, and *.test.mjs). Drift here → false __RUN_ALL__. -const testFiles = globSync( - [ - "tests/unit/*.test.ts", - "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", - "tests/unit/**/*.test.mjs", - "tests/unit/dashboard/**/*.test.ts", - // Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los. - "tests/unit/serial/**/*.test.ts", - ], - { cwd: ROOT, absolute: true } -); -const map = {}; -for (const tf of testFiles) { - const relTest = path.relative(ROOT, tf); - for (const src of sourceDepsOf(tf)) { - (map[src] ||= []).push(relTest); +export function buildTestImpactMap(root = ROOT) { + const testFiles = globSync( + [ + "tests/unit/*.test.ts", + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", + "tests/unit/**/*.test.mjs", + "tests/unit/dashboard/**/*.test.ts", + // Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los. + "tests/unit/serial/**/*.test.ts", + ], + { cwd: root, absolute: true } + ); + const map = {}; + for (const tf of testFiles) { + const relTest = path.relative(root, tf); + for (const src of sourceDepsOf(tf, root)) { + (map[src] ||= []).push(relTest); + } } + for (const k of Object.keys(map)) map[k].sort(); + return { generatedFrom: "import-graph", sources: map, testFileCount: testFiles.length }; +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) { + const result = buildTestImpactMap(); + const { testFileCount, ...map } = result; + const out = path.join(ROOT, "config/quality/test-impact-map.json"); + fs.writeFileSync(out, JSON.stringify(map, null, 2) + "\n"); + console.log( + `test-impact-map: ${Object.keys(map.sources).length} source files mapped from ${testFileCount} test files` + ); } -for (const k of Object.keys(map)) map[k].sort(); -const out = path.join(ROOT, "config/quality/test-impact-map.json"); -fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n"); -console.log( - `test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files` -); diff --git a/tests/unit/check-forgotten-sibling-tests-allowlist.test.ts b/tests/unit/check-forgotten-sibling-tests-allowlist.test.ts new file mode 100644 index 0000000000..7e0dc161d9 --- /dev/null +++ b/tests/unit/check-forgotten-sibling-tests-allowlist.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + analyzeForgottenSiblingTests, + validateAllowlist, +} from "../../scripts/check/check-forgotten-sibling-tests.mjs"; + +test("allowlist entries require consumer, candidate test, rationale, and tracking reference", () => { + assert.throws( + () => validateAllowlist([{ consumer: "src/lib/a.ts", candidateTest: "tests/unit/a.test.ts" }]), + /rationale/ + ); + assert.throws( + () => + validateAllowlist([ + { + consumer: "src/lib/a.ts", + candidateTest: "tests/unit/a.test.ts", + rationale: "Covered by the integration suite during this migration.", + reference: "later", + }, + ]), + /reference/ + ); +}); + +test("allowlist cannot suppress deleted, skipped, or todo candidate tests", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "forgotten-sibling-allowlist-")); + const files = { + "src/lib/value.ts": "export const value = 1;\n", + "src/lib/consumer.ts": 'import { value } from "./value";\n', + "tests/unit/consumer.test.ts": 'test.skip("masked", () => {});\n', + }; + for (const [file, contents] of Object.entries(files)) { + const absolute = path.join(root, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, contents); + } + const allowlist = validateAllowlist([ + { + consumer: "src/lib/consumer.ts", + candidateTest: "tests/unit/consumer.test.ts", + rationale: "Temporarily covered by an integration suite while the unit fixture is repaired.", + reference: "#9530", + }, + ]); + + for (const status of ["D", "M"]) { + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: [ + { status: "M", file: "src/lib/value.ts" }, + { status, file: "tests/unit/consumer.test.ts" }, + ], + impactMap: { sources: { "src/lib/consumer.ts": ["tests/unit/consumer.test.ts"] } }, + allowlist, + addedTestLines: status === "M" ? ['+test.todo("still missing");'] : [], + }); + + assert.equal(result.suppressed.length, 0); + assert.equal(result.maskingRisks.length, 1); + } +}); + +test("a reviewed exception suppresses only its unchanged candidate test", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "forgotten-sibling-reviewed-")); + for (const [file, contents] of Object.entries({ + "src/lib/value.ts": "export const value = 1;\n", + "src/lib/consumer.ts": 'import { value } from "./value";\n', + "tests/unit/consumer.test.ts": 'import "../../src/lib/consumer";\n', + })) { + const absolute = path.join(root, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, contents); + } + + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: [{ status: "M", file: "src/lib/value.ts" }], + impactMap: { sources: { "src/lib/consumer.ts": ["tests/unit/consumer.test.ts"] } }, + allowlist: validateAllowlist([ + { + consumer: "src/lib/consumer.ts", + candidateTest: "tests/unit/consumer.test.ts", + rationale: "Covered by the integration suite while this consumer is being migrated.", + reference: "#9530", + }, + ]), + }); + + assert.equal(result.findings.length, 0); + assert.equal(result.suppressed.length, 1); +}); diff --git a/tests/unit/check-forgotten-sibling-tests.test.ts b/tests/unit/check-forgotten-sibling-tests.test.ts new file mode 100644 index 0000000000..612ee2f917 --- /dev/null +++ b/tests/unit/check-forgotten-sibling-tests.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { analyzeForgottenSiblingTests } from "../../scripts/check/check-forgotten-sibling-tests.mjs"; + +function fixture(files: Record) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "forgotten-sibling-")); + for (const [file, contents] of Object.entries(files)) { + const absolute = path.join(root, file); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, contents); + } + return root; +} + +test("detects a missing sibling test for a static consumer", () => { + const root = fixture({ + "src/lib/value.ts": "export const value = 1;\n", + "src/lib/consumer.ts": 'import { value } from "./value";\nexport const doubled = value * 2;\n', + "tests/unit/consumer.test.ts": 'import "../../src/lib/consumer";\n', + }); + + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: [{ status: "M", file: "src/lib/value.ts" }], + impactMap: { sources: { "src/lib/consumer.ts": ["tests/unit/consumer.test.ts"] } }, + allowlist: [], + }); + + assert.deepEqual(result.findings, [ + { + changedModule: "src/lib/value.ts", + changedSymbols: [], + consumer: "src/lib/consumer.ts", + candidateTest: "tests/unit/consumer.test.ts", + reason: "candidate sibling test is absent from the PR diff", + }, + ]); +}); + +test("updating the candidate sibling test clears the finding", () => { + const root = fixture({ + "src/lib/value.ts": "export const value = 1;\n", + "src/lib/consumer.ts": 'import { value } from "./value";\n', + "tests/unit/consumer.test.ts": 'import "../../src/lib/consumer";\n', + }); + + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: [ + { status: "M", file: "src/lib/value.ts" }, + { status: "M", file: "tests/unit/consumer.test.ts" }, + ], + impactMap: { sources: { "src/lib/consumer.ts": ["tests/unit/consumer.test.ts"] } }, + allowlist: [], + }); + + assert.equal(result.findings.length, 0); +}); + +test("barrel and dynamic-import consumers remain advisory diagnostics", () => { + const root = fixture({ + "src/lib/value.ts": "export const value = 1;\n", + "src/lib/index.ts": 'export { value } from "./value";\n', + "src/lib/lazy.ts": 'export const load = () => import("./value");\n', + "tests/unit/index.test.ts": 'import "../../src/lib/index";\n', + "tests/unit/lazy.test.ts": 'import "../../src/lib/lazy";\n', + }); + + const result = analyzeForgottenSiblingTests({ + root, + changedEntries: [{ status: "M", file: "src/lib/value.ts" }], + impactMap: { + sources: { + "src/lib/index.ts": ["tests/unit/index.test.ts"], + "src/lib/lazy.ts": ["tests/unit/lazy.test.ts"], + }, + }, + allowlist: [], + }); + + assert.equal(result.findings.length, 0); + assert.deepEqual( + result.diagnostics.map(({ consumer, kind }) => ({ consumer, kind })), + [ + { consumer: "src/lib/index.ts", kind: "barrel" }, + { consumer: "src/lib/lazy.ts", kind: "dynamic-import" }, + ] + ); +});