mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user