From c089ca5812bedecb3de1b5551739334506fa3e6e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:40:05 -0700 Subject: [PATCH] fix(ci): preserve rename boundaries in React Doctor gate (#32159) * fix(ci): preserve rename boundaries in React Doctor gate * fix(ci): retain coverage and reject inconsistent audit reports * test(ci): update Git status fixture for rename-aware ranges --- config/scripts/check-changed-code-quality.mjs | 49 ++- .../check-changed-code-quality.test.mjs | 35 +- config/scripts/check-react-doctor-changed.mjs | 232 ++++++++-- .../check-react-doctor-changed.test.mjs | 406 +++++++++++++++++- .../moved-code-normalization-budget.test.mjs | 3 + config/scripts/run-audit-tool.mjs | 8 +- config/scripts/run-audit-tool.test.mjs | 16 + 7 files changed, 706 insertions(+), 43 deletions(-) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 6840ca4cffc..b5ed905ce62 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -144,7 +144,11 @@ function resolveBase(root, requestedBase) { throw new Error('Pass the pull request base SHA or make origin/main available locally.') } -export function collectAddedLineRanges(root, requestedBase) { +export function collectAddedLineRanges( + root, + requestedBase, + { includePath = (file) => isRootCodeQualityPath(file) && SOURCE_FILE_PATTERN.test(file) } = {} +) { // On a pull_request checkout HEAD is the merge commit, so its first parent is the base side and // no merge base has to be computed. Resolving it first is what lets CI checkout shallowly: the // payload base SHA can lag HEAD^1 by any number of commits and need not be in the graph at all. @@ -153,23 +157,44 @@ export function collectAddedLineRanges(root, requestedBase) { const comparisonBase = resolvePullRequestDiffBase(root, null) ?? runGit(root, ['merge-base', resolveBase(root, requestedBase), 'HEAD']).trim() - const changedFiles = splitNullDelimited( - runGit(root, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUB', comparisonBase, '--']) + const changedEntries = splitNullDelimited( + runGit(root, ['diff', '--name-status', '--find-renames', '-z', comparisonBase, '--']) ) + const changedFiles = [] + const renamedSources = new Map() + for (let index = 0; index < changedEntries.length;) { + const status = changedEntries[index++] + const source = changedEntries[index++] + if (status.startsWith('R') || status.startsWith('C')) { + const destination = changedEntries[index++] + changedFiles.push(destination) + if (status.startsWith('R') && includePath(source)) { + renamedSources.set(destination, source) + } + } else if (status !== 'D') { + changedFiles.push(source) + } + } const untrackedFiles = splitNullDelimited( runGit(root, ['ls-files', '--others', '--exclude-standard', '-z']) ) const rangesByFile = new Map() for (const file of changedFiles) { - if ( - !isRootCodeQualityPath(file) || - !SOURCE_FILE_PATTERN.test(file) || - !existsSync(path.join(root, file)) - ) { + if (!includePath(file) || !existsSync(path.join(root, file))) { continue } - const diff = runGit(root, ['diff', '--unified=0', '--no-color', comparisonBase, '--', file]) + const source = renamedSources.get(file) + const paths = source ? [source, file] : [file] + const diff = runGit(root, [ + 'diff', + '--find-renames', + '--unified=0', + '--no-color', + comparisonBase, + '--', + ...paths + ]) const ranges = parseAddedLineRanges(diff) if (ranges.length > 0) { rangesByFile.set(file, ranges) @@ -178,11 +203,7 @@ export function collectAddedLineRanges(root, requestedBase) { for (const file of untrackedFiles) { const absolutePath = path.join(root, file) - if ( - !isRootCodeQualityPath(file) || - !SOURCE_FILE_PATTERN.test(file) || - !existsSync(absolutePath) - ) { + if (!includePath(file) || !existsSync(absolutePath)) { continue } const lineCount = readFileSync(absolutePath, 'utf8').split(/\r?\n/).length diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 7916ded4315..047fe1e79b3 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' @@ -7,6 +7,7 @@ import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' import { OXLINT_SCANS, collectBaseLineBlocks, + collectAddedLineRanges, diagnosticTouchesAddedLines, isUnloadedPluginDirectiveUnusedWarning, isMovedCode, @@ -75,6 +76,38 @@ describe('changed-code quality line matching', () => { expect(isRootCodeQualityPath('cloud/apps/relay/src/index.ts')).toBe(false) expect(isRootCodeQualityPath('src/main/index.ts')).toBe(true) }) + + it.each([ + ['Counter.tsx', []], + ['Counter.txt', [{ start: 1, end: 2 }]], + ['cloud/Counter.tsx', [{ start: 1, end: 2 }]] + ])('retains rename subtraction only within the included scope: %s', (source, expected) => { + const repo = mkdtempSync(path.join(tmpdir(), 'changed-code-quality-entry-')) + try { + const git = (...args) => { + const result = runProcessSync({ program: 'git', args, cwd: repo }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() + } + git('init', '--quiet') + const hooks = path.join(repo, '.git', 'test-hooks') + mkdirSync(hooks) + git('config', 'core.hooksPath', hooks) + git('config', 'commit.gpgsign', 'false') + mkdirSync(path.dirname(path.join(repo, source)), { recursive: true }) + writeFileSync(path.join(repo, source), 'const counter = 1\nexport { counter }\n') + git('add', source) + git('-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'base') + const base = git('rev-parse', 'HEAD') + git('mv', source, 'MovedCounter.tsx') + + expect(collectAddedLineRanges(repo, base).rangesByFile.get('MovedCounter.tsx') ?? []).toEqual( + expected + ) + } finally { + rmSync(repo, { recursive: true, force: true }) + } + }) }) describe('moved-code exemption', () => { diff --git a/config/scripts/check-react-doctor-changed.mjs b/config/scripts/check-react-doctor-changed.mjs index 558754c8f91..8e27af3648d 100644 --- a/config/scripts/check-react-doctor-changed.mjs +++ b/config/scripts/check-react-doctor-changed.mjs @@ -1,30 +1,212 @@ +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import path from 'node:path' import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { collectAddedLineRanges, overlapsAddedLines } from './check-changed-code-quality.mjs' import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs' import { runAuditTool } from './run-audit-tool.mjs' -const requestedBase = - process.argv.slice(2).find((argument) => argument !== '--') ?? - process.env.ORCA_CODE_QUALITY_BASE ?? - 'origin/main' -const base = resolvePullRequestDiffBase(process.cwd(), requestedBase) -// Accept revision syntax, never shell fragments or option injection. -const GIT_REVISION = /^(?!-)[A-Za-z0-9._/@^~-]+$/ -if (!GIT_REVISION.test(base)) { - throw new Error(`Refusing to pass an unsafe diff base to React Doctor: ${base}`) -} -const result = runAuditTool('react-doctor', [ - '.', - '--yes', - '--scope', - 'lines', - '--base', - base, - '--include-untracked', - '--no-dead-code', - '--no-supply-chain', - '--no-telemetry', - '--blocking', - 'error' -]) +const SOURCE_FILE_PATTERN = /\.(?:astro|tsx?|jsx?|mts|mjs|[hH][tT][mM][lL])$/ -process.exitCode = result.code === 0 && !result.signal && !result.timedOut ? 0 : result.code || 1 +function isCount(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +function reportPath(root, file, directory = root) { + if (typeof file !== 'string' || file.length === 0) { + throw new Error('React Doctor returned an invalid file path.') + } + const absolute = file.startsWith('file://') + ? fileURLToPath(file) + : path.resolve(directory, file.replaceAll('\\', '/')) + const canonical = realpathSync(absolute) + const relative = path.relative(root, canonical) + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error('React Doctor returned a path outside the report root.') + } + return canonical +} + +function readCompleteDiagnostics(stdout, root, comparisonBase) { + const report = JSON.parse(stdout) + if ( + report?.schemaVersion !== 3 || + report.ok !== true || + report.error !== null || + report.mode !== 'diff' || + report.baselineDegraded || + typeof report.version !== 'string' || + report.version.length === 0 || + !Number.isFinite(report.elapsedMilliseconds) || + report.elapsedMilliseconds < 0 || + !path.isAbsolute(report.directory ?? '') || + reportPath(root, report.directory) !== root || + report.diff?.baseBranch !== comparisonBase || + !(report.diff.currentBranch === null || typeof report.diff.currentBranch === 'string') || + !isCount(report.diff.changedFileCount) || + typeof report.diff.isCurrentChanges !== 'boolean' || + !Array.isArray(report.projects) || + !Array.isArray(report.diagnostics) || + (report.skippedProjects !== undefined && + (!Array.isArray(report.skippedProjects) || report.skippedProjects.length > 0)) + ) { + throw new Error('React Doctor did not return a complete diff report.') + } + const diagnostics = [] + const flattened = [] + const analyzedPaths = new Set() + let affectedFiles = 0 + for (const project of report.projects) { + if ( + project?.complete !== true || + !path.isAbsolute(project.directory ?? '') || + !path.isAbsolute(project.packageRoot ?? '') || + !Array.isArray(project.diagnostics) || + !Array.isArray(project.skippedChecks) || + project.skippedChecks.length > 0 || + (project.skippedCheckReasons !== undefined && + (!project.skippedCheckReasons || + typeof project.skippedCheckReasons !== 'object' || + Array.isArray(project.skippedCheckReasons) || + Object.keys(project.skippedCheckReasons).length > 0)) || + !Array.isArray(project.analyzedFiles) || + !isCount(project.analyzedFileCount) || + project.analyzedFileCount !== project.analyzedFiles.length || + !isCount(project.scannedFileCount) || + project.scannedFileCount !== project.analyzedFileCount + ) { + throw new Error('React Doctor returned incomplete project coverage.') + } + reportPath(root, project.directory) + const packageRoot = reportPath(root, project.packageRoot) + const analyzedFiles = new Set( + project.analyzedFiles.map((file) => reportPath(root, file, packageRoot)) + ) + if (analyzedFiles.size !== project.analyzedFileCount) { + throw new Error('React Doctor returned duplicate analyzed paths.') + } + for (const file of analyzedFiles) { + analyzedPaths.add(file) + } + const diagnosedFiles = new Set() + for (const diagnostic of project.diagnostics) { + if ( + !['filePath', 'normalizedFilePath', 'id', 'plugin', 'rule', 'message'].every( + (field) => typeof diagnostic?.[field] === 'string' && diagnostic[field].length > 0 + ) || + !['error', 'warning'].includes(diagnostic.severity) || + !isCount(diagnostic.line) || + !isCount(diagnostic.column) || + (diagnostic.endLine !== undefined && + (!isCount(diagnostic.endLine) || diagnostic.endLine < diagnostic.line)) || + ['offset', 'length', 'endColumn'].some( + (field) => diagnostic[field] !== undefined && !isCount(diagnostic[field]) + ) + ) { + throw new Error('React Doctor returned an invalid diagnostic identity or span.') + } + const absolute = reportPath(root, diagnostic.filePath, packageRoot) + const normalized = path.relative(packageRoot, absolute).split(path.sep).join('/') + const file = path.relative(root, absolute).split(path.sep).join('/') + const digest = createHash('sha256') + .update(JSON.stringify([diagnostic.severity, diagnostic.message])) + .digest('hex') + const identity = `${file}::${diagnostic.line}:${diagnostic.column}::${diagnostic.plugin}/${diagnostic.rule}::${digest}` + if ( + diagnostic.normalizedFilePath !== normalized || + diagnostic.id !== identity || + !analyzedFiles.has(absolute) + ) { + throw new Error('React Doctor returned inconsistent diagnostic paths or coverage.') + } + diagnosedFiles.add(normalized) + diagnostics.push({ diagnostic, file }) + flattened.push(diagnostic) + } + affectedFiles += diagnosedFiles.size + } + const summary = report.summary + if ( + !isDeepStrictEqual(report.diagnostics, flattened) || + analyzedPaths.size > report.diff.changedFileCount || + !summary || + summary.totalDiagnosticCount !== flattened.length || + summary.errorCount !== + flattened.filter((diagnostic) => diagnostic.severity === 'error').length || + summary.warningCount !== + flattened.filter((diagnostic) => diagnostic.severity === 'warning').length || + summary.affectedFileCount !== affectedFiles + ) { + throw new Error('React Doctor returned inconsistent diagnostics or summary counts.') + } + return diagnostics +} + +export function main( + root = process.cwd(), + requestedBase = process.argv.slice(2).find((argument) => argument !== '--') ?? + process.env.ORCA_CODE_QUALITY_BASE ?? + 'origin/main', + run = runAuditTool +) { + root = realpathSync(root) + const base = resolvePullRequestDiffBase(root, requestedBase) + // Accept revision syntax, never shell fragments or option injection. + if (!/^(?!-)[A-Za-z0-9._/@^~-]+$/.test(base)) { + throw new Error(`Refusing to pass an unsafe diff base to React Doctor: ${base}`) + } + const { comparisonBase, rangesByFile } = collectAddedLineRanges(root, base, { + includePath: (file) => SOURCE_FILE_PATTERN.test(file) + }) + const result = run( + 'react-doctor', + [ + '.', + '--yes', + '--scope', + 'files', + '--base', + comparisonBase, + '--include-untracked', + '--no-dead-code', + '--no-supply-chain', + '--no-telemetry', + '--blocking', + 'none', + '--json' + ], + { root, stdio: 'pipe' } + ) + if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) { + throw new Error(`React Doctor scan failed: ${result.stderr || result.signal || result.code}`) + } + const diagnostics = readCompleteDiagnostics(result.stdout, root, comparisonBase) + let failures = 0 + for (const { diagnostic, file } of diagnostics) { + const ranges = rangesByFile.get(file) + if ( + diagnostic.severity === 'error' && + ranges && + (diagnostic.line === 0 || + overlapsAddedLines(diagnostic.line, diagnostic.endLine ?? diagnostic.line, ranges)) + ) { + console.error( + `${file}:${diagnostic.line} ${diagnostic.plugin}/${diagnostic.rule}: ${diagnostic.message}` + ) + failures += 1 + } + } + console.log(`React Doctor changed-lines gate: ${failures} error(s).`) + return failures > 0 ? 1 : 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.exitCode = main() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} diff --git a/config/scripts/check-react-doctor-changed.test.mjs b/config/scripts/check-react-doctor-changed.test.mjs index 8cb4d1d00b3..d846ff28239 100644 --- a/config/scripts/check-react-doctor-changed.test.mjs +++ b/config/scripts/check-react-doctor-changed.test.mjs @@ -1,7 +1,13 @@ import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { runProcessSync } from '@orca/process-host' import path from 'node:path' import process from 'node:process' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { main } from './check-react-doctor-changed.mjs' +import { collectAddedLineRanges } from './check-changed-code-quality.mjs' const repoRoot = path.resolve(import.meta.dirname, '..', '..') const script = path.join(repoRoot, 'config', 'scripts', 'check-react-doctor-changed.mjs') @@ -26,3 +32,401 @@ describe('check-react-doctor-changed diff base', () => { } ) }) + +const temporaryRoots = [] +const source = [ + "import { useState } from 'react'", + '', + 'export function Counter() {', + ' const [count, setCount] = useState(0)', + ' function change() {', + ' setCount((current) => {', + " localStorage.setItem('count', String(current))", + ' return current + 1', + ' })', + ' }', + ' return ', + '}', + '' +].join('\n') + +function git(root, ...args) { + const result = runProcessSync({ + program: 'git', + args, + cwd: root, + timeoutMs: null, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout.trim() +} + +function createRepository() { + const root = mkdtempSync(path.join(tmpdir(), 'orca-react-doctor-gate-')) + temporaryRoots.push(root) + git(root, 'init', '--quiet') + git(root, 'config', 'user.email', 'test@example.com') + git(root, 'config', 'user.name', 'Test') + git(root, 'config', 'commit.gpgsign', 'false') + const hooks = path.join(root, '.git', 'test-hooks') + mkdirSync(hooks) + git(root, 'config', 'core.hooksPath', hooks) + writeFileSync(path.join(root, 'Counter.tsx'), source) + git(root, 'add', '.') + git(root, 'commit', '--quiet', '-m', 'base') + return { root, base: git(root, 'rev-parse', 'HEAD') } +} + +function report(root, base, file, line = 6, endLine = 9, reportRoot = root) { + const relative = path.relative(reportRoot, path.resolve(root, file)).split(path.sep).join('/') + const message = 'State updater has side effects' + const digest = createHash('sha256') + .update(JSON.stringify(['error', message])) + .digest('hex') + const diagnostic = { + filePath: file, + normalizedFilePath: file, + line, + column: 5, + endLine, + severity: 'error', + plugin: 'react-doctor', + rule: 'no-impure-state-updater', + message, + id: `${relative}::${line}:5::react-doctor/no-impure-state-updater::${digest}`, + help: 'Keep state updaters pure', + category: 'Bugs', + tags: [] + } + return { + schemaVersion: 3, + version: '0.9.14', + mode: 'diff', + ok: true, + error: null, + directory: reportRoot, + elapsedMilliseconds: 0, + diff: { + baseBranch: base, + currentBranch: 'fixture', + changedFileCount: 1, + isCurrentChanges: false + }, + diagnostics: [diagnostic], + summary: { + errorCount: 1, + warningCount: 0, + totalDiagnosticCount: 1, + affectedFileCount: 1, + score: null, + scoreLabel: null + }, + projects: [ + { + directory: root, + packageRoot: root, + framework: 'vite', + project: {}, + score: null, + complete: true, + skippedChecks: [], + diagnostics: [diagnostic], + analyzedFiles: [file], + analyzedFileCount: 1, + scannedFileCount: 1, + elapsedMilliseconds: 0 + } + ] + } +} + +function replaceProjectDiagnostic(value, changes) { + const diagnostic = { ...value.diagnostics[0], ...changes } + const digest = createHash('sha256') + .update(JSON.stringify([diagnostic.severity, diagnostic.message])) + .digest('hex') + diagnostic.id = `${diagnostic.id.slice(0, diagnostic.id.lastIndexOf('::'))}::${digest}` + return { ...value, projects: [{ ...value.projects[0], diagnostics: [diagnostic] }] } +} + +function runReport(root, base, value, overrides = {}) { + return main(root, base, () => ({ + code: 0, + signal: null, + timedOut: false, + stdout: JSON.stringify(value), + stderr: '', + ...overrides + })) +} + +afterEach(() => { + vi.restoreAllMocks() + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('React Doctor rename-aware changed-line gate', () => { + it('does not treat an unchanged renamed updater as added when an import changes', () => { + const { root, base } = createRepository() + mkdirSync(path.join(root, 'nested folder')) + const file = 'nested folder/Moved Counter.tsx' + renameSync(path.join(root, 'Counter.tsx'), path.join(root, file)) + writeFileSync(path.join(root, file), source.replace("from 'react'", "from 'react-compatible'")) + git(root, 'add', '.') + + expect(collectAddedLineRanges(root, base).rangesByFile.get(file)).toEqual([ + { start: 1, end: 1 } + ]) + expect(runReport(root, base, report(root, base, file))).toBe(0) + }) + + it('blocks a new side effect inside an existing renamed updater span', () => { + const { root, base } = createRepository() + const file = 'MovedCounter.tsx' + renameSync(path.join(root, 'Counter.tsx'), path.join(root, file)) + writeFileSync( + path.join(root, file), + source.replace( + ' return current + 1', + " localStorage.setItem('new', 'effect')\n return current + 1" + ) + ) + git(root, 'add', '.') + vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(runReport(root, base, report(root, base, file, 6, 10))).toBe(1) + }) + + it('blocks a copied violation while retaining the old file', () => { + const { root, base } = createRepository() + writeFileSync( + path.join(root, 'CopiedCounter.tsx'), + readFileSync(path.join(root, 'Counter.tsx')) + ) + git(root, 'add', '.') + vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(runReport(root, base, report(root, base, 'CopiedCounter.tsx'))).toBe(1) + }) + + it('blocks a new untracked file containing a violation', () => { + const { root, base } = createRepository() + writeFileSync(path.join(root, 'NewCounter.tsx'), source) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(runReport(root, base, report(root, base, 'NewCounter.tsx'))).toBe(1) + }) + + it.each([ + 'cloud/Counter.tsx', + 'page.html', + 'page.HTML', + 'page.astro', + 'module.mts', + 'module.mjs' + ])('retains React Doctor coverage for %s', (file) => { + const { root, base } = createRepository() + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }) + writeFileSync(path.join(root, file), source) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(runReport(root, base, report(root, base, file))).toBe(1) + }) + + it('resolves nested project diagnostics relative to their concrete package root', () => { + const { root, base } = createRepository() + mkdirSync(path.join(root, 'mobile')) + writeFileSync(path.join(root, 'mobile/Counter.tsx'), source) + const value = report(path.join(root, 'mobile'), base, 'Counter.tsx', 6, 9, root) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(runReport(root, base, value)).toBe(1) + }) + + it('accepts a complete empty report for intentionally ignored source', () => { + const { root, base } = createRepository() + const value = report(root, base, 'Counter.tsx') + value.projects = [] + value.diagnostics = [] + value.summary = { + ...value.summary, + errorCount: 0, + affectedFileCount: 0, + totalDiagnosticCount: 0 + } + + expect(runReport(root, base, value)).toBe(0) + }) + + it.each([ + ['inconsistent diagnostics', (value) => ({ ...value, diagnostics: [] }), {}], + ['malformed JSON', (value) => value, { stdout: '{' }], + ['unsupported schema', (value) => ({ ...value, schemaVersion: 1 }), {}], + ['failed report', (value) => ({ ...value, ok: false }), {}], + ['missing diagnostics', (value) => ({ ...value, diagnostics: undefined }), {}], + ['missing diff', (value) => ({ ...value, diff: undefined }), {}], + ['missing summary', (value) => ({ ...value, summary: undefined }), {}], + ['wrong base', (value) => ({ ...value, diff: { ...value.diff, baseBranch: 'HEAD' } }), {}], + ['wrong report root', (value) => ({ ...value, directory: path.dirname(value.directory) }), {}], + [ + 'invalid changed-file count', + (value) => ({ ...value, diff: { ...value.diff, changedFileCount: -1 } }), + {} + ], + [ + 'coverage exceeding changed files', + (value) => ({ ...value, diff: { ...value.diff, changedFileCount: 0 } }), + {} + ], + [ + 'incorrect summary count', + (value) => ({ ...value, summary: { ...value.summary, errorCount: 0 } }), + {} + ], + [ + 'incorrect affected-file count', + (value) => ({ ...value, summary: { ...value.summary, affectedFileCount: 0 } }), + {} + ], + [ + 'missing analyzed paths', + (value) => ({ ...value, projects: [{ ...value.projects[0], analyzedFiles: undefined }] }), + {} + ], + [ + 'missing analyzed count', + (value) => ({ ...value, projects: [{ ...value.projects[0], analyzedFileCount: undefined }] }), + {} + ], + [ + 'missing scanned count', + (value) => ({ ...value, projects: [{ ...value.projects[0], scannedFileCount: undefined }] }), + {} + ], + [ + 'incorrect analyzed count', + (value) => ({ ...value, projects: [{ ...value.projects[0], analyzedFileCount: 2 }] }), + {} + ], + [ + 'incorrect scanned count', + (value) => ({ ...value, projects: [{ ...value.projects[0], scannedFileCount: 2 }] }), + {} + ], + [ + 'duplicate analyzed paths', + (value) => ({ + ...value, + diff: { ...value.diff, changedFileCount: 2 }, + projects: [ + { + ...value.projects[0], + analyzedFiles: ['Counter.tsx', 'Counter.tsx'], + analyzedFileCount: 2, + scannedFileCount: 2 + } + ] + }), + {} + ], + [ + 'diagnostic missing from declared coverage', + (value) => ({ + ...value, + projects: [ + { ...value.projects[0], analyzedFiles: [], analyzedFileCount: 0, scannedFileCount: 0 } + ] + }), + {} + ], + [ + 'equal-count diagnostic severity mismatch', + (value) => replaceProjectDiagnostic(value, { severity: 'warning' }), + {} + ], + [ + 'equal-count diagnostic message mismatch', + (value) => replaceProjectDiagnostic(value, { message: 'different' }), + {} + ], + [ + 'equal-count diagnostic annotation mismatch', + (value) => ({ + ...value, + projects: [ + { ...value.projects[0], diagnostics: [{ ...value.diagnostics[0], help: 'different' }] } + ] + }), + {} + ], + [ + 'inconsistent normalized path', + (value) => ({ + ...value, + projects: [ + { + ...value.projects[0], + diagnostics: [{ ...value.diagnostics[0], normalizedFilePath: 'Other.tsx' }] + } + ] + }), + {} + ], + [ + 'inconsistent diagnostic identity', + (value) => ({ + ...value, + projects: [ + { ...value.projects[0], diagnostics: [{ ...value.diagnostics[0], id: 'Other.tsx' }] } + ] + }), + {} + ], + [ + 'skipped-check reason', + (value) => ({ + ...value, + projects: [{ ...value.projects[0], skippedCheckReasons: { 'lint:partial': 'timed out' } }] + }), + {} + ], + [ + 'incomplete project', + (value) => ({ ...value, projects: [{ ...value.projects[0], complete: false }] }), + {} + ], + [ + 'skipped project', + (value) => ({ ...value, skippedProjects: [{ reason: 'max-duration' }] }), + {} + ], + [ + 'skipped lint', + (value) => ({ ...value, projects: [{ ...value.projects[0], skippedChecks: ['lint'] }] }), + {} + ], + ['degraded baseline', (value) => ({ ...value, baselineDegraded: true }), {}], + [ + 'invalid span', + (value) => ({ + ...value, + projects: [{ ...value.projects[0], diagnostics: [{ ...value.diagnostics[0], endLine: 1 }] }] + }), + {} + ], + ['crashed process', (value) => value, { code: 134 }], + ['terminated process', (value) => value, { signal: 'SIGABRT' }], + ['timed out process', (value) => value, { timedOut: true }], + ['truncated output', (value) => value, { outputTruncated: true }] + ])('fails closed on %s', (_label, change, overrides) => { + const { root, base } = createRepository() + expect(() => + runReport(root, base, change(report(root, base, 'Counter.tsx')), overrides) + ).toThrow() + }) +}) diff --git a/config/scripts/moved-code-normalization-budget.test.mjs b/config/scripts/moved-code-normalization-budget.test.mjs index cd2e729608c..11360e68ed6 100644 --- a/config/scripts/moved-code-normalization-budget.test.mjs +++ b/config/scripts/moved-code-normalization-budget.test.mjs @@ -119,6 +119,9 @@ describe('moved-code base normalization budget', () => { if (args[0] === 'ls-files') { return '' } + if (args.includes('--name-status')) { + return 'M\0fixture.mjs\0' + } if (args.includes('--name-only')) { return 'fixture.mjs\0' } diff --git a/config/scripts/run-audit-tool.mjs b/config/scripts/run-audit-tool.mjs index 242d33d6c97..dbe5afec435 100644 --- a/config/scripts/run-audit-tool.mjs +++ b/config/scripts/run-audit-tool.mjs @@ -34,7 +34,11 @@ export function installAuditTools(root = ROOT, run = runProcessSync) { }) } -export function runAuditTool(tool, args, { root = ROOT, run = runProcessSync } = {}) { +export function runAuditTool( + tool, + args, + { root = ROOT, run = runProcessSync, stdio = 'inherit' } = {} +) { const entry = auditToolEntry(tool, root) // Recheck the manifest and lock on every run; an old install must not bypass a reviewed update. const installed = installAuditTools(root, run) @@ -45,7 +49,7 @@ export function runAuditTool(tool, args, { root = ROOT, run = runProcessSync } = program: process.execPath, args: [entry, ...args], cwd: root, - stdio: 'inherit', + stdio, timeoutMs: null }) } diff --git a/config/scripts/run-audit-tool.test.mjs b/config/scripts/run-audit-tool.test.mjs index 449808de38a..acaef5e1f33 100644 --- a/config/scripts/run-audit-tool.test.mjs +++ b/config/scripts/run-audit-tool.test.mjs @@ -38,6 +38,22 @@ describe('locked audit tool execution', () => { expect(result).toBe(failed) }) + it('captures structured tool output without hiding the installation log', () => { + const calls = [] + const output = { ...success, stdout: '{"ok":true}' } + expect( + runAuditTool('react-doctor', ['.', '--json'], { + stdio: 'pipe', + run: (spec) => { + calls.push(spec) + return calls.length === 1 ? success : output + } + }) + ).toBe(output) + expect(calls[0].stdio).toBe('inherit') + expect(calls[1].stdio).toBe('pipe') + }) + it.each([ { code: 1, signal: null, timedOut: false }, { code: null, signal: 'SIGTERM', timedOut: false },