Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ tsconfig.vitest-temp.json
# The part-reach oracle lints scratch programs inside the repository; an
# interrupted run can leave them behind.
/part-reach-scratch-*/

# Other checkouts and scratch copies. A plain directory here is not part of
# this checkout's branch, so the oracle lint hooks must not see its files.
/.worktrees/
21 changes: 15 additions & 6 deletions docs/contributing/oracle-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3386,13 +3386,22 @@ budget a helper computes and passes in as parameters.

`scripts/lint-scope-oracle.property.test.js` owns lint selection after an
edit (`scripts.lint-scope`): the changed scope, the named (pre-commit) scope,
and the Claude edit hook. Its model is a fixed-point closure over the drawn
import graph, independent of the script's forward search. It covers
and the Claude edit and stop hooks. Its model is a fixed-point closure over the
drawn import graph, independent of the script's forward search. It covers
oracle-to-oracle imports, untracked oracle files, and `.js` oracle files. It
does not draw `.js` support modules or drivers, although the rule now resolves
a `.js` test module when no TypeScript source exists for it (the part-reach
oracle owns that resolution). It passes file names in their real-path spelling:
a name given through a symlinked path is not covered.
also covers the checkout rule from #2280. A session works in the launch
checkout or in a worktree nested under its `.worktrees/`. The hooks run the
launch checkout's script with the session checkout as the input's `cwd`. They
must select that checkout's files, and they must report through that
checkout's own copy of the rule. A plain scratch directory under `.worktrees/`
must produce no report. The repository's `.gitignore` is part of the tested
configuration. It does not draw `.js` support modules or drivers, although the
rule now resolves a `.js` test module when no TypeScript source exists for it
(the part-reach oracle owns that resolution). It passes file names in their
real-path spelling: a name given through a symlinked path is not covered. It
does not generate a hook input without `cwd`, a worktree outside the launch
checkout, or a worktree branch that has no copy of the script. In that last
case the launch checkout's rule lints the worktree.

`packages/db/tests/oracle-replay.test.ts` checks every replay manifest owner:
from its cwd, the command's files exist, its target is the owner, and a Vitest
Expand Down
81 changes: 57 additions & 24 deletions scripts/agent-lint-feedback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,16 @@
* shell command reaches the model at the end of the turn, when the stop hook
* lints every changed file. When a harness's hook input names no file that
* this script can find, the edit hook lints every changed file as well.
*
* A hook lints the checkout that the agent works in, which the hook input's
* `cwd` names. A harness can run this script from the checkout where the
* session started while the agent works in a worktree under `.worktrees/`.
* When `cwd` is in another checkout that has its own copy of this script, that
* copy runs instead, so a branch is linted by its own rule, as CI lints it.
* Without `cwd`, the checkout is the one that holds this script.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
Expand All @@ -45,12 +52,10 @@ import { resolveImportedTestModule } from './eslint-plugin-oracle/rules/no-unit-
import {
describeLoweredRunBudget,
isOracleFile,
loweredRunBudgets,
loweredRunBudgetsBetween,
mergeBase,
} from './check-oracle-run-counts.mjs'

const root = fileURLToPath(new URL(`../`, import.meta.url))

const { values: options, positionals } = parseArgs({
options: {
format: { type: `string`, default: `text` },
Expand All @@ -68,6 +73,30 @@ if (![`edit`, `stop`].includes(options.event)) {
throw new Error(`--event must be edit or stop`)
}
const isHook = options.format !== `text`
const inputText = isHook ? readFileSync(0, `utf8`) : ``
const input = inputText.trim() === `` ? {} : JSON.parse(inputText)
const scriptRoot = realpathSync(fileURLToPath(new URL(`../`, import.meta.url)))

/** The top level of the git checkout that holds `cwd`, or undefined. */
function checkoutOf(cwd) {
try {
return realpathSync(
execFileSync(`git`, [`rev-parse`, `--show-toplevel`], {
cwd,
encoding: `utf8`,
stdio: [`ignore`, `pipe`, `ignore`],
}).trim(),
)
} catch {
return undefined
}
}

const root =
(typeof input.cwd === `string` ? checkoutOf(input.cwd) : undefined) ??
scriptRoot
const ownScript = join(root, `scripts`, `agent-lint-feedback.mjs`)
const delegate = root !== scriptRoot && existsSync(ownScript)

function git(...args) {
return execFileSync(`git`, args, { cwd: root, encoding: `utf8` }).trim()
Expand All @@ -83,8 +112,9 @@ function repositoryFiles(...outputs) {
/**
* Every oracle file in the working tree, tracked or new. A new file that an
* agent has not added yet is linted like any other. Git leaves out ignored
* builds, and lists another checkout nested in this one, such as a worktree
* under `.worktrees/`, as a directory, which is not an oracle file.
* files: builds, and `.worktrees/`, where other checkouts and scratch copies
* live. A plain directory there has no `.git` entry, so without the ignore rule
* git would list each of its files as new work on this branch.
*/
function oracleFilesInTree() {
return repositoryFiles(
Expand Down Expand Up @@ -153,7 +183,7 @@ function affectedOracleFiles(files) {
* uncommitted work.
*/
function changedFiles() {
const base = mergeBase()
const base = mergeBase(root)
return repositoryFiles(
git(`diff`, `--name-only`, `--diff-filter=ACMR`, base),
git(`ls-files`, `--others`, `--exclude-standard`),
Expand Down Expand Up @@ -197,12 +227,6 @@ function editedFiles(input) {
)
}

function readHookInput() {
if (!isHook) return {}
const text = readFileSync(0, `utf8`)
return text.trim() === `` ? {} : JSON.parse(text)
}

function createLinter() {
return new ESLint({
cwd: root,
Expand Down Expand Up @@ -337,20 +361,29 @@ function selectFiles(input) {
*/
function runBudgetReport(scope) {
if (scope !== `changed`) return ``
return loweredRunBudgets()
return loweredRunBudgetsBetween(root, mergeBase(root))
.map(
(budget) =>
`${budget.file}: ${describeLoweredRunBudget(budget)}. Restore it, or explain why in the pull request.`,
)
.join(`\n`)
}

const input = readHookInput()
const { scope, files } = selectFiles(input)
const report = [
reportText(await lint(createLinter(), files)),
runBudgetReport(scope),
]
.filter(Boolean)
.join(`\n`)
respond(report, input, scope)
if (delegate) {
// The session's checkout runs its own copy, which writes the response.
const result = spawnSync(
process.execPath,
[ownScript, ...process.argv.slice(2)],
{ cwd: root, input: inputText, stdio: [`pipe`, `inherit`, `inherit`] },
)
process.exitCode = result.status ?? 1
} else {
const { scope, files } = selectFiles(input)
const report = [
reportText(await lint(createLinter(), files)),
runBudgetReport(scope),
]
.filter(Boolean)
.join(`\n`)
respond(report, input, scope)
}
4 changes: 2 additions & 2 deletions scripts/check-oracle-run-counts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ function git(cwd, ...args) {
* The commit where this branch left main. Without one, the branch's own
* history cannot be found, so this fails instead of comparing too little.
*/
export function mergeBase() {
export function mergeBase(repo = root) {
for (const branch of [`origin/main`, `main`]) {
try {
return git(root, `merge-base`, `HEAD`, branch)
return git(repo, `merge-base`, `HEAD`, branch)
} catch {
// Try the next branch.
}
Expand Down
Loading
Loading