mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* feat(acp): ACP agents can declare a managed runtime installed on first chat start An ACP launch spec may name a pinned runtime Orca installs under its locked data folder. The stock lookup searches it last (Command, PATH, install directories, managed folder); a start whose lookup missed installs it under the acquisition's signal, then resolves the launch again. A Command setting, restart-recovery reads and catalog probes never install. The pinned-runtime materializer can now publish a verified multi-member folder: extract once into staging, check each member's sha256, rename into place, with a per-asset download size limit. Node's runtime path is unchanged. * Fix ACP managed runtime lookup precedence * feat(acp): ACP agents can run with a private per-process temp folder * feat(acp): old managed runtime versions are removed at host start Once per host start, the ACP registration removes every version beside an agent's current managed runtime folder, and staging left by an earlier process. One host owns the data root and ACP servers are never reattached across host runs, so nothing runs from them. This process's own staging is kept (a publish may be in flight). Best effort: a failure is logged and never holds up a launch. * feat(acp): a sign-in prompt on an ACP agent's stderr ends the start or turn signed out An agent that waits for a browser sign-in prints a link on stderr and blocks for minutes. A launch spec can now name that line (signInPrompt); the connection then closes with a typed sign-in failure through the existing process owner, so a start reads notSignedIn and a running turn fails signed out instead of settling unknown. The matched line, which carries the sign-in URL, never enters the retained stderr tail, errors or diagnostics. * Prevent sign-in URLs leaking from long stderr lines * feat(acp): an agent can enforce the permission mode itself through a session config option An optional launch-spec field names the session config option and its ask/full values. Every new or reopened session gets the value for the chat's full-access posture before it is exposed, and a refusal fails the start. With the field present Orca no longer answers permission requests for the person. No agent sets it yet. * fix(acp): reject unconfirmed permission mode during acquisition * feat(acp): an agent enforcing the permission mode itself runs the chat's own mode The launch spec maps each chat permission mode to its session option value; a start sets the value for the mode the host launches with (ask when it names none), replacing the global Agent Permissions full-access read. A mid-chat change takes effect through the host's existing restart of a child launched with another mode before the next send. * Keep ACP session acquisition within the line limit * feat(acp): open Antigravity in the Chat UI over its ACP server With the experimental chat setting on, Antigravity chats run Google's Antigravity ACP server, which Orca installs on the first chat start: a pinned 1.3.0 release whose archive and both executables are verified by hash (macOS arm64/x64, Linux x64/arm64). Each host keeps one private sign-in folder for these chats under Orca's data folder, made owner-only before every start; the server keeps its sign-in there as a file. A chat signs in with the server's Google method through the existing auth-required retry; with no saved sign-in the server's sign-in prompt ends the chat signed out. The Agent Permissions setting maps to the server's own mode, and a question the server asks shows as a question that takes one of its choices, never auto-answered. A chat reopens its session with session/resume. * fix(acp): separate Antigravity chat command and require pinned host * test(acp): assert Antigravity launch arguments across hosts * feat(acp): Antigravity chats offer Ask, Accept edits and Full access per chat Each mode maps to the server's own (default, auto_edit, yolo). No Approve for me: the server has no automatic reviewer. Accept edits gets its own description, since auto_edit approves file edit tools only. * feat(native-chat): offer host-provided chats without their CLI detected A host now marks each agentSession.agents row whose chat program Orca provides there (an ACP managed runtime with a pinned build on this host). With Chat UI on, the tab-bar picker, the empty-workspace default chat and the new-agent shortcut offer those agents beside the detected ones when the launch opens as a chat. Terminal launches still follow detection, and an older host that sends no mark keeps today's detection-only offer. * Fix CLI-free chat fallback and picker updates * Fix admission refusal reason test expectation * feat(native-chat): the host health snapshot says whether each chat's program is on the machine Antigravity's chat runs Orca's own download of Google's ACP program, not the agy CLI, so clients could not tell from CLI detection whether it can open as a chat. Each snapshot row now carries an optional chatProgram (available, installable, missing, unknown), found through the agent's own launch resolver without spawning anything, and re-derived after a managed runtime install or removal. Health state and reason are unchanged, so older clients decode the frame as before. * feat(native-chat): one renderer reading of whether an agent can open as a chat on its host Pickers asked CLI detection whether an agent can open as a chat, which is wrong for Antigravity, whose chat runs a program Orca downloads. This module answers from the host's published chat program fact, and for hosts that publish none, from the host's managed-runtime mark and install capability or else CLI detection. Facts not yet learned read as unlearned, not as no. * fix(native-chat): a declined chat opens its agent's terminal only where the CLI is installed Once a chat can be offered without its CLI, a host refusal used to fall back to a terminal that runs a CLI that is not there. The one admission boundary now reports the host's refusal instead when the workspace host's detected agents lack the CLI, for new-tab, work-item and workspace creation launches alike. A caller's own fallback, like the empty workspace's shell, still opens. * feat(native-chat): the + menu and Settings offer a chat wherever its program runs With Chat UI on and no agy CLI, Antigravity had no chat row in the + menu (or a disabled 'Not installed' one when switched on), because both read CLI detection. The menu now reads the host's chat-program answer: a chat row where the program runs or installs from the chat, no '(CLI)' row or terminal button without the CLI. A failed CLI check no longer keeps the menu from learning that answer. Settings shows an unchosen provider on when its CLI or the program Orca runs for it (downloaded or found outside Orca) is installed, and holds the switch until that is known. A saved default that opens as a chat no longer says it is not detected. * feat(native-chat): a default agent that opens as a chat starts without its CLI An empty workspace and the new-agent shortcut skipped a default agent whose CLI is not detected, even when it opens as a chat on that host (Antigravity without agy). Both now honor that default, waiting briefly for the host's chat facts when they are not known yet, while the automatic pick with no default still chooses only detected agents. Blank and the shell fallback are unchanged. * feat(native-chat): new workspaces, work items, continuations and the dashboard offer chat-only agents The new-workspace composer, work-item launches, session continuation and the dashboard launcher listed only agents whose CLI is detected, so an agent that opens as a chat without its CLI could not be picked, and an explicit pick of it was dropped. They now also offer agents that open as a chat on the workspace's host, keep such an explicit choice, and the dashboard republishes when the host's chat facts arrive. Automatic picks still choose only detected agents. * feat(native-chat): Source Control and Fix Checks accept a chosen agent that opens as a chat The Source Control action dialog, its saved auto-start, the checks panel, recovery launches and Fix Checks (before and after its workspace exists) rejected a saved or default agent whose CLI is not detected, even where it opens as a chat. They now accept it there and list it, through the shared source-control pick, while the automatic pick still chooses only detected agents. * fix(native-chat): an empty workspace does not wait on chat facts for a Claude or Codex default Claude and Codex have no program Orca downloads, so without their CLI only the host's own answer could offer them as a chat; holding the workspace empty for the host's agent list could not change that. * test(native-chat): pin the chat-program fact, cold explicit defaults and the CLI-less decline Covers the host's program lookup and its re-read after an install, older and unknown snapshot values falling back to the managed mark and install capability of the same host, an explicit default opening as a chat with CLI detection failed while automatic and blank picks stay on detected agents, kept composer and saved Source Control choices, and a declined chat reporting the refusal instead of opening a terminal for a missing CLI on both launch paths. * refactor(source-control): the action dialog's agent list lives in its own hook The dialog hook was over the file-length limit after it learned which agents open as a chat without their CLI; detection and that offer now live together in use-source-control-launchable-agents. * fix: typed test mocks and record the dialog's pre-existing reset effect The Source Control dialog's open-cycle reset effect was already on main; the changed-code gate reported it because the effect now also learns which agents open as a chat. * fix(native-chat): a declined chat refuses its terminal only when the CLI is known to be missing When a host declines a chat, Orca falls back to that agent's terminal. A chat can now be offered without its CLI, so the fallback must not open a terminal that cannot start. But a failed or still-pending CLI check is not proof the CLI is missing: treating it that way blocked a working terminal fallback (for example an OpenCode version that runs in a terminal but not as a chat). Each detection producer (local, paired server, SSH) now records the outcome of its latest attempt for the exact target, next to the IDs it already keeps. The decline check refuses the terminal only when the same host's latest attempt succeeded and did not list the CLI; pending, failed or unresolved outcomes keep the existing fallback, and a caller's own shell fallback is untouched. * fix(native-chat): every launch surface learns whether a chat opens before it decides A chat whose program runs on the host without its CLI (for example Antigravity) could be missing, greyed out or swapped for another agent depending on which surface launched it: the composer dropped an explicit pick while the host was still answering, Fix Checks and the Checks panel refused a saved chat agent, an inactive paired host was never asked about its chats, the Providers switch flashed off, and session continuation auto-picked an agent that was only offered. One lease per host, runtime and pairing now acquires the host's agent list and health stream for whoever needs it: the + menu, composers, Settings, the dashboard and its pop-out, Source Control and Checks, Fix Checks, work items, shortcuts and continuation. Actions wait for those facts within one bounded deadline (ordinary CLI detection keeps its own wait), cancel when the workspace's host is genuinely replaced, and re-read settings before launching. A saved, default or explicit choice may open as a chat without its CLI; automatic picks still come only from detected agents. Pending or unknown facts never erase a choice or flash a switch off. The dashboard's holds are reconciled by the publishers that read them, and a hold that notifies while it is being created no longer leaks a second stream holder. * fix: the new-workspace composer memoizes its launch workspace from its own fields * fix: a declined chat reads the launch host's own agent inventory; test fixtures match their types * fix: work-item launches detect before waiting on chat facts; the composer and Source Control list agents from one owner The composer card no longer holds its own chat-facts lease beside its modal's, Source Control reads agent inventory only while its dialog is open, and a work-item launch keeps the new workspace's own detection when the store has not loaded that workspace yet. * fix(native-chat): keep acquisition tied to the current host resource * fix(native-chat): verify the current acquisition before returning choices * fix(i18n): retain legacy terminal-list English entries in the runtime catalog * fix(native-chat): the dashboard republishes launch options when the active workspace's host changes Both dashboard publishers now watch the active workspace and its selected host, which own its launch options. Two QuickLaunchButton tests assert the current "couldn't list terminal agents" wording. Restores base forms and drops a duplicate workspace helper to stay inside the change budget. * test: the dashboard publisher test reads its watched state from the function it tests * fix(native-chat): dashboards watch one checked route-input view; saved Source Control discovery settles every outcome Both dashboard publishers observe and derive launch options from one exhaustive, compiler-checked view of the store slices the launch-route readers take, so a route input can no longer be missed by one list. A saved Source Control action's discovery now ends as success, cancelled or failed; only success can auto-start, and a cancelled or failed attempt reveals the editable dialog instead of launching on a replacement runtime. Restores base forms and shares one worktree workspace helper to stay inside the change budget. * style: format the runtime metadata resolver * fix: merge duplicate imports the code-quality gate rejects * test: the dashboard watch fixture seeds every route-input field * test: the dashboard watch fixture is fully typed without a cast * fix: an unknown agent scan stays unknown for Fix Checks and recovery, and a saved Checks agent keeps its message * fix: the action-first chat path asks the same missing-CLI check before opening a declined terminal; compact to the size cap * refactor: compact the chat-program changes to the size cap after merging main * Pickers list an agent that can't open here as disabled with its reason instead of hiding it The Create workspace and continuation pickers only searched the agents the caller could launch, so an enabled agent with no chat on this host (for example Antigravity when the host can't run it) lost its disabled row, while the + menu showed it. * Continuation test accepts more than one disabled subfolder row now that disabled rows are listed * Re-run CI: the code scan was cancelled across the repository * test: cover chat selection without terminal CLI * test: compact host chat program fixtures
600 lines
21 KiB
JavaScript
600 lines
21 KiB
JavaScript
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { runProcessSync } from '@orca/process-host'
|
|
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
|
|
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
|
|
|
|
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
|
|
const ROOT_CODE_QUALITY_IGNORED_PREFIXES = ['cloud/']
|
|
const CASTING_RULE = 'typescript/consistent-type-assertions'
|
|
const CASTING_DISABLE_PATTERN =
|
|
/\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*typescript\/consistent-type-assertions/
|
|
const ANTI_SLOP_DISABLE_PATTERN =
|
|
/\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*\banti-slop\//
|
|
const REACT_DOCTOR_DISABLE_PATTERN =
|
|
/^\s*\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s+react-doctor\/[\w-]+(?:\s*,\s*react-doctor\/[\w-]+)*\s*(?:--(?:(?!\*\/).)*)?(?:\*\/)?\s*$/
|
|
const EXPLICIT_DISABLE_RULE_PATTERN = /(?:-disable(?:-next-line|-line)?\s+|^)[\w-]+(?:\/[\w-]+)?/
|
|
export const OXLINT_SCANS = [
|
|
{
|
|
// Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root
|
|
// config would apply root rules to mobile/, whose .oxlintrc.json turns them off.
|
|
label: 'code quality',
|
|
args: ['--report-unused-disable-directives-severity', 'warn']
|
|
},
|
|
{
|
|
label: 'casting code quality',
|
|
args: ['--config', 'config/oxlint-code-quality-casting.json']
|
|
},
|
|
{
|
|
// Why the allow: CI's `audit:code-quality:native` runs before the mobile install, so it can
|
|
// never see a cycle inside mobile/ — locally, where mobile/node_modules exists, it would.
|
|
label: 'focused plugins',
|
|
args: [
|
|
'--config',
|
|
'config/oxlint-code-quality-native-plugins.json',
|
|
'--allow',
|
|
'import/no-cycle'
|
|
]
|
|
},
|
|
{
|
|
label: 'type-aware code quality',
|
|
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']
|
|
},
|
|
{
|
|
label: 'React Doctor',
|
|
args: [
|
|
'--config',
|
|
'config/oxlint-react-doctor.json',
|
|
'--report-unused-disable-directives-severity',
|
|
'warn'
|
|
]
|
|
},
|
|
{
|
|
// Why changed-lines only: the renderer carries ~4.7k pre-existing restyle/raw-color
|
|
// findings. Gating added lines holds the line without a repo-wide migration.
|
|
label: 'design system',
|
|
args: ['--config', 'config/oxlint-design-system.json']
|
|
}
|
|
]
|
|
|
|
const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
|
|
[
|
|
'react-doctor(no-adjust-state-on-prop-change)',
|
|
new Set([
|
|
'src/renderer/src/components/use-task-page-github-issue-draft.ts',
|
|
'src/renderer/src/components/use-task-page-jira-creation-state.ts'
|
|
])
|
|
],
|
|
[
|
|
'react-doctor(no-derived-state-effect)',
|
|
new Set([
|
|
'src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts',
|
|
// The open-cycle reset effect predates this entry; it surfaced when its body changed.
|
|
'src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts'
|
|
])
|
|
],
|
|
[
|
|
// The rule wants one named handle cleared by name. Both startup effects arm a variable number
|
|
// of refresh timers, every one of them through addTimer into `timers`, which their cleanups
|
|
// clear -- a shape the rule reports whether the handles live in an array, a Set, or a nested
|
|
// helper. The finding predates this list; it surfaced when the effect body changed. This map
|
|
// keys on file, not line, so the entry covers both effects in it; nothing else in the file
|
|
// arms a timer, so widening it further is the only alternative, not a narrower option.
|
|
'react-doctor(effect-needs-cleanup)',
|
|
new Set(['mobile/src/session/use-mobile-session-startup.ts'])
|
|
]
|
|
])
|
|
|
|
export function parseAddedLineRanges(diff) {
|
|
const ranges = []
|
|
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
for (const line of diff.split(/\r?\n/)) {
|
|
const match = hunkPattern.exec(line)
|
|
if (!match) {
|
|
continue
|
|
}
|
|
const start = Number.parseInt(match[1], 10)
|
|
const count = match[2] === undefined ? 1 : Number.parseInt(match[2], 10)
|
|
if (count > 0) {
|
|
ranges.push({ start, end: start + count - 1 })
|
|
}
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
export function overlapsAddedLines(startLine, endLine, ranges) {
|
|
return ranges.some((range) => startLine <= range.end && endLine >= range.start)
|
|
}
|
|
|
|
function runGit(root, args, options = {}) {
|
|
return execFileSync('git', args, {
|
|
cwd: root,
|
|
encoding: options.encoding ?? 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024
|
|
})
|
|
}
|
|
|
|
function splitNullDelimited(output) {
|
|
return output.split('\0').filter(Boolean)
|
|
}
|
|
|
|
export function isRootCodeQualityPath(file) {
|
|
return !ROOT_CODE_QUALITY_IGNORED_PREFIXES.some((prefix) => file.startsWith(prefix))
|
|
}
|
|
|
|
function resolveBase(root, requestedBase) {
|
|
for (const candidate of [
|
|
requestedBase,
|
|
process.env.ORCA_CODE_QUALITY_BASE,
|
|
'origin/main',
|
|
'main'
|
|
]) {
|
|
if (!candidate) {
|
|
continue
|
|
}
|
|
const result = spawnSync('git', ['rev-parse', '--verify', `${candidate}^{commit}`], {
|
|
cwd: root,
|
|
stdio: 'ignore'
|
|
})
|
|
if (result.status === 0) {
|
|
return candidate
|
|
}
|
|
}
|
|
throw new Error('Pass the pull request base SHA or make origin/main available locally.')
|
|
}
|
|
|
|
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.
|
|
// Off that ref (local runs) the requested base is an arbitrary branch tip, so the merge base is
|
|
// still what isolates this branch's own lines.
|
|
const comparisonBase =
|
|
resolvePullRequestDiffBase(root, null) ??
|
|
runGit(root, ['merge-base', resolveBase(root, requestedBase), 'HEAD']).trim()
|
|
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 (!includePath(file) || !existsSync(path.join(root, file))) {
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
for (const file of untrackedFiles) {
|
|
const absolutePath = path.join(root, file)
|
|
if (!includePath(file) || !existsSync(absolutePath)) {
|
|
continue
|
|
}
|
|
const lineCount = readFileSync(absolutePath, 'utf8').split(/\r?\n/).length
|
|
rangesByFile.set(file, [{ start: 1, end: lineCount }])
|
|
}
|
|
return { comparisonBase, rangesByFile }
|
|
}
|
|
|
|
function parseOxlintOutput(stdout, label) {
|
|
const start = stdout.indexOf('{')
|
|
const end = stdout.lastIndexOf('}')
|
|
if (start === -1 || end === -1) {
|
|
throw new Error(`${label} did not return Oxlint JSON output.`)
|
|
}
|
|
return JSON.parse(stdout.slice(start, end + 1))
|
|
}
|
|
|
|
function normalizedDiagnosticPath(root, filename) {
|
|
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
|
return path.relative(root, absolutePath).split(path.sep).join('/')
|
|
}
|
|
|
|
function diagnosticLineRange(root, filename, span) {
|
|
const startLine = span.line
|
|
if (!Number.isInteger(startLine)) {
|
|
return null
|
|
}
|
|
if (!Number.isInteger(span.offset) || !Number.isInteger(span.length) || span.length === 0) {
|
|
return { start: startLine, end: startLine }
|
|
}
|
|
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
|
const source = readFileSync(absolutePath)
|
|
const highlighted = source.subarray(span.offset, span.offset + span.length).toString('utf8')
|
|
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
|
|
}
|
|
|
|
// Why: a file-splitting refactor makes every line of the new module an "added"
|
|
// line, so pre-existing lint debt in code that merely MOVED starts failing the
|
|
// changed-lines gate. The only way to satisfy it is to edit the moved code,
|
|
// which is exactly what a behavior-preserving refactor must not do. So a
|
|
// diagnostic is exempt when its highlighted lines already existed, verbatim and
|
|
// contiguous, somewhere in the base revision of the files this change touches.
|
|
function normalizeSourceLine(line) {
|
|
return line.replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function readBaseSourceBatch(root, comparisonBase, files) {
|
|
// Git 2.25 supports --batch; its newline protocol cannot represent newline paths.
|
|
if (files.some((file) => /[\r\n]/.test(`${comparisonBase}:${file}`))) {
|
|
return null
|
|
}
|
|
try {
|
|
const result = runProcessSync({
|
|
program: 'git',
|
|
args: ['cat-file', '--batch'],
|
|
cwd: root,
|
|
input: files.map((file) => `${comparisonBase}:${file}\n`).join(''),
|
|
captureStdoutAsBytes: true,
|
|
maxOutputBytes: 64 * 1024 * 1024,
|
|
timeoutMs: null
|
|
})
|
|
if (result.code !== 0 || !result.stdoutBytes) {
|
|
return null
|
|
}
|
|
const output = result.stdoutBytes
|
|
const sources = []
|
|
let offset = 0
|
|
for (const file of files) {
|
|
const end = output.indexOf(10, offset)
|
|
if (end === -1) {
|
|
return null
|
|
}
|
|
const header = output.subarray(offset, end).toString('utf8')
|
|
offset = end + 1
|
|
if (header === `${comparisonBase}:${file} missing`) {
|
|
continue
|
|
}
|
|
const match = /^[0-9a-f]{40}(?:[0-9a-f]{24})? blob (\d+)$/.exec(header)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
const size = Number(match[1])
|
|
if (!Number.isSafeInteger(size) || output[offset + size] !== 10) {
|
|
return null
|
|
}
|
|
sources.push(output.subarray(offset, offset + size).toString('utf8'))
|
|
offset += size + 1
|
|
}
|
|
return offset === output.length ? sources : null
|
|
} catch {
|
|
// Preserve per-file reads when a batch exceeds the output limit or cannot run.
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function collectBaseLineBlocks(root, comparisonBase, files = null) {
|
|
// Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
|
|
// often deleted or renamed away. Deleted paths never reach the changed-file list
|
|
// (it filters to ACMRTUB), so read every path the diff touches, deletions included.
|
|
// --no-renames: a rename otherwise lists only its new path, which the base lacks.
|
|
const paths =
|
|
files ??
|
|
splitNullDelimited(
|
|
runGit(root, ['diff', '--name-only', '--no-renames', '-z', comparisonBase, '--'])
|
|
).filter((file) => SOURCE_FILE_PATTERN.test(file))
|
|
const blocks = []
|
|
for (let start = 0; start < paths.length; start += 32) {
|
|
const batch = paths.slice(start, start + 32)
|
|
let sources = readBaseSourceBatch(root, comparisonBase, batch)
|
|
if (sources === null) {
|
|
sources = []
|
|
for (const file of batch) {
|
|
try {
|
|
const result = runProcessSync({
|
|
program: 'git',
|
|
args: ['show', `${comparisonBase}:${file}`],
|
|
cwd: root,
|
|
maxOutputBytes: 64 * 1024 * 1024,
|
|
timeoutMs: null
|
|
})
|
|
if (result.code === 0) {
|
|
sources.push(result.stdout)
|
|
}
|
|
} catch {
|
|
// Failed source reads were already skipped by the per-file implementation.
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
for (const source of sources) {
|
|
blocks.push(source.split(/\r?\n/).map(normalizeSourceLine).filter(Boolean))
|
|
}
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
export function isMovedCode(highlightedLines, baseBlocks) {
|
|
return createMovedCodeMatcher(baseBlocks)(highlightedLines)
|
|
}
|
|
|
|
export function createMovedCodeMatcher(baseBlocks) {
|
|
// Base-revision blocks stay fixed for the gate run; normalize each visited block once.
|
|
const normalizedBlocks = new Map()
|
|
return (highlightedLines) => matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks)
|
|
}
|
|
|
|
function matchMovedCode(highlightedLines, baseBlocks, normalizedBlocks) {
|
|
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
|
|
if (needle.length === 0) {
|
|
return false
|
|
}
|
|
// Why a near-match rather than an exact contiguous one: a split moves a block
|
|
// verbatim but a diagnostic's span often reaches past it — most commonly to a
|
|
// hook dependency array, which legitimately grows when closure variables become
|
|
// props. Requiring every line to match would report the moved body as new. So:
|
|
// the block must still start at the same line in the base and appear IN ORDER,
|
|
// and nearly all of it must be present. Genuinely new code shares neither the
|
|
// anchor nor the ordering, so it stays reported.
|
|
const MIN_COVERAGE = 0.9
|
|
return baseBlocks.some((block) => {
|
|
let haystack = normalizedBlocks.get(block)
|
|
if (!haystack) {
|
|
haystack = block.map(normalizeSourceLine).filter((line) => line !== '')
|
|
normalizedBlocks.set(block, haystack)
|
|
}
|
|
for (let start = 0; start < haystack.length; start += 1) {
|
|
if (haystack[start] !== needle[0]) {
|
|
continue
|
|
}
|
|
let matched = 1
|
|
let cursor = start + 1
|
|
for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
|
|
while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
|
|
cursor += 1
|
|
}
|
|
if (cursor < haystack.length) {
|
|
matched += 1
|
|
cursor += 1
|
|
}
|
|
}
|
|
if (matched / needle.length >= MIN_COVERAGE) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
function diagnosticHighlightedLines(root, filename, span) {
|
|
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
|
|
const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
|
|
const range = diagnosticLineRange(root, filename, span)
|
|
if (range === null) {
|
|
return []
|
|
}
|
|
return source.slice(range.start - 1, range.end)
|
|
}
|
|
|
|
export function diagnosticTouchesAddedLines(
|
|
diagnostic,
|
|
rangesByFile,
|
|
root = process.cwd(),
|
|
baseBlocks = [],
|
|
movedCodeMatcher = isMovedCode
|
|
) {
|
|
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
|
const ranges = rangesByFile.get(file)
|
|
if (!ranges) {
|
|
return false
|
|
}
|
|
return (diagnostic.labels ?? []).some((label) => {
|
|
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
|
|
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
|
|
return false
|
|
}
|
|
return !movedCodeMatcher(
|
|
diagnosticHighlightedLines(root, diagnostic.filename, label.span),
|
|
baseBlocks
|
|
)
|
|
})
|
|
}
|
|
|
|
function annotationValue(value) {
|
|
return String(value).replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')
|
|
}
|
|
|
|
function printDiagnostic(diagnostic, root) {
|
|
const file = normalizedDiagnosticPath(root, diagnostic.filename)
|
|
const line = diagnostic.labels?.[0]?.span?.line ?? 1
|
|
const code = diagnostic.code ?? 'oxlint'
|
|
console.error(
|
|
`::error file=${annotationValue(file)},line=${line},title=${annotationValue(code)}::${annotationValue(diagnostic.message)}`
|
|
)
|
|
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
|
|
}
|
|
|
|
// Why: only the casting scan enforces `assertionStyle: never`, so under the root config an
|
|
// `as` cast is legal and the SAFETY: directive AGENTS.md mandates reads as unused. The untyped
|
|
// scan reports that as a warning, which the gate counts, so exempt exactly those directives.
|
|
export function isCastingDirectiveUnusedWarning(diagnostic, root) {
|
|
if (!/^Unused (?:oxlint|eslint)-disable/.test(diagnostic.message ?? '')) {
|
|
return false
|
|
}
|
|
return (diagnostic.labels ?? []).some((label) =>
|
|
diagnosticHighlightedLines(root, diagnostic.filename, label.span).some((line) =>
|
|
CASTING_DISABLE_PATTERN.test(line)
|
|
)
|
|
)
|
|
}
|
|
|
|
// Unloaded plugin directives are checked by their owning scan.
|
|
export function isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scanLabel) {
|
|
if (!/^Unused (?:oxlint|eslint)-disable/.test(diagnostic.message ?? '')) {
|
|
return false
|
|
}
|
|
if (scanLabel === 'React Doctor') {
|
|
const labels = diagnostic.labels ?? []
|
|
return (
|
|
labels.length > 0 &&
|
|
labels.every(({ span }) => {
|
|
if (span.offset === undefined || span.length === undefined) {
|
|
return false
|
|
}
|
|
const file = path.isAbsolute(diagnostic.filename)
|
|
? diagnostic.filename
|
|
: path.join(root, diagnostic.filename)
|
|
// Oxlint spans use UTF-8 byte offsets, including before non-ASCII comments.
|
|
const directive = readFileSync(file)
|
|
.subarray(span.offset, span.offset + span.length)
|
|
.toString('utf8')
|
|
const rules = directive.split('--')[0]
|
|
return EXPLICIT_DISABLE_RULE_PATTERN.test(rules) && !/\breact-doctor\//.test(rules)
|
|
})
|
|
)
|
|
}
|
|
return (diagnostic.labels ?? []).some((label) =>
|
|
diagnosticHighlightedLines(root, diagnostic.filename, label.span).some(
|
|
(line) => ANTI_SLOP_DISABLE_PATTERN.test(line) || REACT_DOCTOR_DISABLE_PATTERN.test(line)
|
|
)
|
|
)
|
|
}
|
|
|
|
// Why: oxlint cannot see the AGENTS.md requirement that every casting suppression carry a
|
|
// line-specific SAFETY: rationale, so the directive text itself is checked over added lines.
|
|
export function findCastingDirectivesMissingSafety(root, rangesByFile) {
|
|
const findings = []
|
|
for (const [file, ranges] of rangesByFile) {
|
|
const absolutePath = path.join(root, file)
|
|
if (!existsSync(absolutePath)) {
|
|
continue
|
|
}
|
|
readFileSync(absolutePath, 'utf8')
|
|
.split(/\r?\n/)
|
|
.forEach((text, index) => {
|
|
const line = index + 1
|
|
if (
|
|
CASTING_DISABLE_PATTERN.test(text) &&
|
|
!text.includes('SAFETY:') &&
|
|
overlapsAddedLines(line, line, ranges)
|
|
) {
|
|
findings.push({
|
|
filename: file,
|
|
code: `${CASTING_RULE} (missing SAFETY:)`,
|
|
message: `Suppressing ${CASTING_RULE} requires a line-specific "SAFETY:" explanation.`,
|
|
labels: [{ span: { line } }]
|
|
})
|
|
}
|
|
})
|
|
}
|
|
return findings
|
|
}
|
|
|
|
function isSuppressedDiagnostic(diagnostic, root) {
|
|
const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code)
|
|
return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false
|
|
}
|
|
|
|
function runOxlintScan(root, scan, files) {
|
|
const { command, prefixArgs } = resolveOxlintInvocation(root)
|
|
const result = spawnSync(command, [...prefixArgs, ...scan.args, '--format', 'json', ...files], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
maxBuffer: 128 * 1024 * 1024,
|
|
windowsHide: true
|
|
})
|
|
if (result.error) {
|
|
throw result.error
|
|
}
|
|
if (!result.stdout.trim()) {
|
|
process.stderr.write(result.stderr)
|
|
throw new Error(`${scan.label} failed before producing diagnostics.`)
|
|
}
|
|
return parseOxlintOutput(result.stdout, scan.label).diagnostics ?? []
|
|
}
|
|
|
|
export function main(
|
|
root = process.cwd(),
|
|
requestedBase = process.argv.slice(2).find((argument) => argument !== '--')
|
|
) {
|
|
const { comparisonBase, rangesByFile } = collectAddedLineRanges(root, requestedBase)
|
|
const files = [...rangesByFile.keys()]
|
|
if (files.length === 0) {
|
|
console.log(
|
|
`Changed-code quality gate: no changed JavaScript or TypeScript since ${comparisonBase.slice(0, 12)}.`
|
|
)
|
|
return 0
|
|
}
|
|
|
|
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
|
|
const movedCodeMatcher = createMovedCodeMatcher(baseBlocks)
|
|
|
|
let failures = 0
|
|
for (const scan of OXLINT_SCANS) {
|
|
const diagnostics = runOxlintScan(root, scan, files).filter(
|
|
(diagnostic) =>
|
|
!isSuppressedDiagnostic(diagnostic, root) &&
|
|
!isCastingDirectiveUnusedWarning(diagnostic, root) &&
|
|
!isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scan.label) &&
|
|
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks, movedCodeMatcher)
|
|
)
|
|
for (const diagnostic of diagnostics) {
|
|
printDiagnostic(diagnostic, root)
|
|
}
|
|
failures += diagnostics.length
|
|
console.log(
|
|
`${scan.label}: ${diagnostics.length} new finding(s) across ${files.length} changed file(s).`
|
|
)
|
|
}
|
|
|
|
const missingSafety = findCastingDirectivesMissingSafety(root, rangesByFile)
|
|
for (const diagnostic of missingSafety) {
|
|
printDiagnostic(diagnostic, root)
|
|
}
|
|
failures += missingSafety.length
|
|
console.log(
|
|
`casting SAFETY: rationale: ${missingSafety.length} new finding(s) across ${files.length} changed file(s).`
|
|
)
|
|
|
|
if (failures > 0) {
|
|
console.error(
|
|
`Changed-code quality gate failed with ${failures} finding(s) since ${comparisonBase.slice(0, 12)}.`
|
|
)
|
|
return 1
|
|
}
|
|
console.log(`Changed-code quality gate passed since ${comparisonBase.slice(0, 12)}.`)
|
|
return 0
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(main())
|
|
}
|