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
This commit is contained in:
Neil
2026-10-22 20:40:05 -07:00
committed by GitHub
parent 77f1523d72
commit c089ca5812
7 changed files with 706 additions and 43 deletions
+35 -14
View File
@@ -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
@@ -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', () => {
+207 -25
View File
@@ -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
}
}
@@ -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 <button onClick={change}>{count}</button>',
'}',
''
].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()
})
})
@@ -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'
}
+6 -2
View File
@@ -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
})
}
+16
View File
@@ -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 },