From e405e297162553d728848736c7249f86702cc9ea Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 28 Oct 2026 10:56:41 -0600 Subject: [PATCH] fix(scripts): lint the agent's checkout in the oracle lint hooks The agent hooks ran the script of the checkout where the session started and linted that checkout, even when the agent worked in a worktree under `.worktrees/`. Plain directories under `.worktrees/` also counted as new work on the launch checkout's branch, so the stop hook returned thousands of reports for files that no session changed. The hooks now resolve the checkout from the hook input's `cwd`. When that checkout has its own copy of the script, the copy runs, so a branch is linted by its own rule. `.gitignore` lists `/.worktrees/`. The lint-scope oracle now draws a launch or worktree session and a scratch directory, runs the stop hook as well, and checks that every report comes from the session checkout's own rule. Fixes #2280. Co-authored-by: Isaac --- .gitignore | 4 + docs/contributing/oracle-coverage.md | 21 +- scripts/agent-lint-feedback.mjs | 81 ++++-- scripts/check-oracle-run-counts.mjs | 4 +- scripts/lint-scope-oracle.property.test.js | 315 +++++++++++++++------ 5 files changed, 306 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 25f59eee75..d1bb2024cd 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index c34f023efc..1e161e6ac2 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -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 diff --git a/scripts/agent-lint-feedback.mjs b/scripts/agent-lint-feedback.mjs index b2df5e122d..c8c43cecaa 100644 --- a/scripts/agent-lint-feedback.mjs +++ b/scripts/agent-lint-feedback.mjs @@ -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' @@ -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` }, @@ -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() @@ -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( @@ -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`), @@ -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, @@ -337,7 +361,7 @@ 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.`, @@ -345,12 +369,21 @@ function runBudgetReport(scope) { .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) +} diff --git a/scripts/check-oracle-run-counts.mjs b/scripts/check-oracle-run-counts.mjs index 45fe4164c2..fa4108338e 100644 --- a/scripts/check-oracle-run-counts.mjs +++ b/scripts/check-oracle-run-counts.mjs @@ -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. } diff --git a/scripts/lint-scope-oracle.property.test.js b/scripts/lint-scope-oracle.property.test.js index c22dab834f..292aa3926e 100644 --- a/scripts/lint-scope-oracle.property.test.js +++ b/scripts/lint-scope-oracle.property.test.js @@ -10,9 +10,22 @@ * `oracle`, in TypeScript or JavaScript, tracked or new and untracked: an * agent's new oracle file is linted like any other. * + * The files belong to the checkout that the agent works in. A hook command can + * run the script of the checkout where the session started, while the agent + * works in a worktree nested under that checkout's `.worktrees/`. The hook + * input's `cwd` names the agent's checkout, so the hooks select and lint that + * checkout's files, with that checkout's own copy of the script and the rule, + * as CI does for its branch. A directory under `.worktrees/` that is not a + * checkout, such as a scratch copy, is never one of the launch checkout's + * changed files. + * * Authority: AGENTS.md "Lint feedback while you work" says the hooks and * `pnpm lint:changed` return the oracle reports for the files that were edited, - * and that the pre-commit hook lints the staged files. AGENTS.md "Name oracle + * and that the pre-commit hook lints the staged files. Issue #2280, written by + * the maintainer, states the checkout rule: a session that started in the main + * checkout and works in `.worktrees/` gets reports only for that + * worktree's branch, a plain directory under `.worktrees/` produces no report, + * and `pnpm lint:changed` in a worktree lints that worktree. AGENTS.md "Name oracle * files for discovery" defines an oracle file by its name. The rule's header * contract (scripts/eslint-plugin-oracle/rules/no-unit-test.js) states that the * rule follows relative imports of test modules and does not follow production @@ -30,17 +43,25 @@ * are drawn for support modules only, and star re-exports are the only form. * The driver runs the lint command-line script in temporary git repositories. * The pre-commit leg is covered only by the script's file selection: the glob in - * lint-staged.config.js is not evaluated here. + * lint-staged.config.js is not evaluated here. A session works in the launch + * checkout or in one worktree at `.worktrees/wt`, and a launch checkout can hold + * one scratch directory. The hooks always pass `cwd`, and the hook process runs + * in the launch checkout: a hook input without `cwd` is not generated, and + * neither is a worktree outside the launch checkout or one whose branch has no + * copy of the script. The repository's own `.gitignore` is copied into each + * temporary repository, so its rules are part of what is tested. * * Structure: * 1. The grammar draws a module graph, the edited and untracked modules, and * the file that an edit hook names. * 2. The model predicts the oracle files to lint for each scope, by a * fixed-point closure over the graph's edges. - * 3. The driver writes the base commit and the edited working tree, then runs - * the CLI's changed scope, its named scope, and its edit-hook form. A - * crashed CLI is a failure, not an empty selection. - * 4. The comparison checks that each observed selection equals the prediction. + * 3. The driver writes the base commit and the edited working tree, in the + * launch checkout or a worktree, then runs the CLI's changed scope, its + * named scope, and its edit-hook and stop-hook forms. A crashed CLI is a + * failure, not an empty selection. + * 4. The comparison checks that each observed selection equals the prediction, + * and that every report came from the session checkout's own rule. * 5. Reach counts the graph states that the campaign enters, and the * campaigns run the same predicate with a fixed seed and a random seed. */ @@ -56,7 +77,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { setImmediate } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import fc from 'fast-check' @@ -163,6 +184,12 @@ const programArbitrary = fc { weight: 2, arbitrary: fc.constant(`rule`) }, ), hookIndex: fc.nat({ max: 9 }), + // The checkout that the session works in: the one where it started, or a + // worktree nested in it. + session: fc.constantFrom(`launch`, `worktree`), + // A plain directory under the launch checkout's `.worktrees/`, holding an + // oracle-named file that the rule reports. + scratch: fc.boolean(), }) .map(({ shape, ...skeleton }) => { const drawn = @@ -359,6 +386,8 @@ function buildProgram(skeleton) { driver, production, ruleEdited: skeleton.ruleEdited, + session: skeleton.session, + scratch: skeleton.scratch, edges, reexports, modules, @@ -385,6 +414,8 @@ function editedByScope(program) { changed: [...tracked, ...untracked, ...rule], named: [...tracked, ...rule], hook: [program.hookTarget], + // The stop hook lints the changed files of the session's checkout. + stop: [...tracked, ...untracked, ...rule], } } @@ -419,17 +450,44 @@ function predictSelected(program, edited) { .sort() } +// Each checkout is linted by its own copy of the rule. The driver marks the +// message of the rule copy that a branch edits, and only that copy, so a report +// carries the mark exactly when the session checkout's branch edited the rule. +// The launch checkout's copy is unmarked in a worktree session, so a report +// that the launch checkout's rule makes for a worktree carries no mark. + +/** Whether the session checkout's reports carry the edited rule's mark. */ +function predictRuleMarked(program) { + return program.ruleEdited +} + // -- 3. Driver ---------------------------------------------------------------- // // The driver runs the lint script that a developer or hook runs. It writes the // modules and the copy of the script and the rule into a temporary repository, -// commits the tracked modules, edits them in the working tree, and writes the -// untracked oracle files. It runs the changed scope (`lint:changed`), the named -// scope (the pre-commit hook), and the edit hook in that one repository: the -// hook selects from the file its input names, not from the working tree's -// edits. Each report line names its oracle file. The driver reads those names; -// it does not compute them. An exit status or stderr that is not a report is a -// failed run, so a crashed script cannot pass as an empty selection. +// the launch checkout, with the repository's `.gitignore`, and commits the +// tracked modules. A worktree session then adds a worktree of that commit at +// `.worktrees/wt`, on its own branch. The session checkout gets the edits and +// the untracked oracle files. A scratch directory is a plain directory of +// untracked files under the launch checkout's `.worktrees/`. +// +// The changed scope (`lint:changed`) and the named scope (the pre-commit hook) +// run the session checkout's own script there, as a developer runs them. The +// edit hook and the stop hook run the launch checkout's script from the launch +// checkout, as a harness that started there runs them, with the session +// checkout as the input's `cwd`. The edit hook selects from the file that its +// input names, not from the working tree's edits. Each report line names its +// oracle file, and the driver reads those names and whether the line carries +// the edited rule's mark; it does not compute them. An exit status or stderr +// that is not a report is a failed run, so a crashed script cannot pass as an +// empty selection. + +const WORKTREE = `.worktrees/wt` +const SCRATCH = `.worktrees/scratch/${APP}/tests/scratch-oracle.test.ts` +// The rule's message for these generated files, and the mark that an edited +// copy of the rule adds to it. +const RULE_MESSAGE = `registers tests and declares no oracle part.` +const RULE_MARK = `[branch rule]` /** The text of one module, with or without its edit. */ function moduleText(program, module, edited) { @@ -444,18 +502,20 @@ function moduleText(program, module, edited) { reexported.has(target) ? `export * from '${spec}'` : `import '${spec}'`, ) } - if (module.kind === `oracle`) { - lines.push(`it('reads one value', () => { expect(1).toBe(1) })`) - } + if (module.kind === `oracle`) lines.push(UNDECLARED_TEST) if (module.kind === `production`) lines.push(`export const prod = 1`) if (edited) lines.push(`// edited`) return `${lines.join(`\n`)}\n` } +// A test in a file that declares nothing, so the rule reports the file. +const UNDECLARED_TEST = `it('reads one value', () => { expect(1).toBe(1) })` + let work let pluginDir let feedbackScript let runCountScript +let gitignoreFile beforeAll(() => { // Each run has its own directory, so concurrent runs cannot remove each @@ -463,111 +523,151 @@ beforeAll(() => { // passes in the spelling that the script's repository root uses. work = realpathSync(mkdtempSync(join(tmpdir(), `lint-scope-oracle-`))) pluginDir = join(repoRoot, `scripts`, `eslint-plugin-oracle`) - // A mutant is run from a copy: LINT_SCOPE_SCRIPT names the copy under test. + // A mutant is run from a copy: LINT_SCOPE_SCRIPT names the copy of the + // script under test, and LINT_SCOPE_GITIGNORE the copy of `.gitignore`. feedbackScript = process.env.LINT_SCOPE_SCRIPT ?? join(repoRoot, `scripts`, `agent-lint-feedback.mjs`) runCountScript = join(repoRoot, `scripts`, `check-oracle-run-counts.mjs`) + gitignoreFile = + process.env.LINT_SCOPE_GITIGNORE ?? join(repoRoot, `.gitignore`) }) afterAll(() => { rmSync(work, { recursive: true, force: true }) }) -/** Writes the base files, commits them, then writes the edited working tree. */ +function gitIn(cwd, ...args) { + return execFileSync( + `git`, + [ + `-c`, + `user.name=oracle`, + `-c`, + `user.email=oracle@example.invalid`, + ...args, + ], + { cwd, encoding: `utf8`, stdio: [`ignore`, `pipe`, `pipe`] }, + ) +} + +/** The path of a module in a checkout. */ +function modulePath(checkout, name) { + if (name === RULE) return join(checkout, RULE) + if (name === PRODUCTION) return join(checkout, APP, `src`, `prod.ts`) + return join(checkout, APP, `tests`, name) +} + +/** + * Writes the base files and commits them in the launch checkout, adds the + * session's worktree, then writes the edited working tree of the session + * checkout and the scratch directory. + */ function repositoryOf(program) { - const directory = mkdtempSync(join(work, `r`)) - const git = (...args) => - execFileSync( - `git`, - [ - `-c`, - `user.name=oracle`, - `-c`, - `user.email=oracle@example.invalid`, - ...args, - ], - { - cwd: directory, - encoding: `utf8`, - stdio: [`ignore`, `pipe`, `pipe`], - }, - ) - git(`init`, `-q`, `-b`, `main`) + const launch = mkdtempSync(join(work, `r`)) + gitIn(launch, `init`, `-q`, `-b`, `main`) writeFileSync( - join(directory, `package.json`), + join(launch, `package.json`), // The repository's root is an ES module package, as here. JSON.stringify({ name: `root`, type: `module` }), ) - mkdirSync(join(directory, APP, `tests`), { recursive: true }) - mkdirSync(join(directory, APP, `src`), { recursive: true }) + writeFileSync(join(launch, `.gitignore`), readFileSync(gitignoreFile, `utf8`)) + mkdirSync(join(launch, APP, `tests`), { recursive: true }) + mkdirSync(join(launch, APP, `src`), { recursive: true }) writeFileSync( - join(directory, APP, `package.json`), + join(launch, APP, `package.json`), JSON.stringify({ name: `app` }), ) // The plugin's sources, without its tests: its own oracle file would be an // oracle file of this repository too. - cpSync(pluginDir, join(directory, `scripts`, `eslint-plugin-oracle`), { + cpSync(pluginDir, join(launch, `scripts`, `eslint-plugin-oracle`), { recursive: true, filter: (source) => !source.endsWith(`.test.js`), }) writeFileSync( - join(directory, `scripts`, `agent-lint-feedback.mjs`), + join(launch, `scripts`, `agent-lint-feedback.mjs`), readFileSync(feedbackScript, `utf8`), ) writeFileSync( - join(directory, `scripts`, `check-oracle-run-counts.mjs`), + join(launch, `scripts`, `check-oracle-run-counts.mjs`), readFileSync(runCountScript, `utf8`), ) - const pathOf = (name) => - name === RULE - ? join(directory, RULE) - : name === PRODUCTION - ? join(directory, APP, `src`, `prod.ts`) - : join(directory, APP, `tests`, name) for (const module of program.modules) { if (!module.untracked) - writeFileSync(pathOf(module.name), moduleText(program, module, false)) + writeFileSync( + modulePath(launch, module.name), + moduleText(program, module, false), + ) + } + gitIn(launch, `add`, `-A`) + gitIn(launch, `commit`, `-q`, `-m`, `base`) + // A worktree nested in the launch checkout finds these packages by walking + // up from its own files. + symlinkSync(join(repoRoot, `node_modules`), join(launch, `node_modules`)) + let checkout = launch + if (program.session === `worktree`) { + gitIn(launch, `worktree`, `add`, `-q`, `-b`, `feature`, WORKTREE) + checkout = join(launch, WORKTREE) + } + if (program.scratch) { + mkdirSync(dirname(join(launch, SCRATCH)), { recursive: true }) + writeFileSync(join(launch, SCRATCH), `${UNDECLARED_TEST}\n`) } - git(`add`, `-A`) - git(`commit`, `-q`, `-m`, `base`) - symlinkSync(join(repoRoot, `node_modules`), join(directory, `node_modules`)) for (const module of program.modules) { if (module.edited || module.untracked) - writeFileSync(pathOf(module.name), moduleText(program, module, true)) + writeFileSync( + modulePath(checkout, module.name), + moduleText(program, module, true), + ) } if (program.ruleEdited) { - const rule = pathOf(RULE) - writeFileSync(rule, `${readFileSync(rule, `utf8`)}\n// edited\n`) + const rule = modulePath(checkout, RULE) + const text = readFileSync(rule, `utf8`) + if (!text.includes(RULE_MESSAGE)) + throw new Error(`the rule no longer has the message ${RULE_MESSAGE}`) + writeFileSync( + rule, + `${text.replace(RULE_MESSAGE, `${RULE_MESSAGE} ${RULE_MARK}`)}\n// edited\n`, + ) + } + return { + launch, + checkout, + pathOf: (name) => modulePath(checkout, name), } - return { directory, pathOf } } const lintLine = /^(\S+?):\d+:\d+ .* \((oracle\/[\w-]+|parse)\)$/ const budgetLine = /^\S+: .+: run budget (lowered|removed) / -/** The oracle basenames in a report's lint lines. */ +/** + * The oracle basenames in a report's lint lines, and the set of the lines' + * marks: whether each one carries the edited rule's mark. + */ function reportedOracles(lines) { const found = new Set() + const marks = new Set() for (const line of lines) { const match = lintLine.exec(line) - if (match !== null) found.add(match[1].split(`/`).pop()) + if (match === null) continue + found.add(match[1].split(`/`).pop()) + marks.add(line.includes(RULE_MARK)) } - return [...found].sort() + return { oracles: [...found].sort(), marks } } /** - * Runs the CLI and returns the oracle files that it reported. In text format - * the CLI exits 0 with no output, or 1 with report lines on stderr. As a - * Claude edit hook it exits 0 with no output, or 2 with a report on stderr. + * Runs a copy of the CLI from `cwd` and returns what it reported. In text + * format the CLI exits 0 with no output, or 1 with report lines on stderr. As a + * Claude hook it exits 0 with no output, or 2 with a report on stderr. * Anything else, such as a crash, which exits 1 with a stack trace, fails. */ -function runCli(directory, args, input) { - const result = spawnSync( - process.execPath, - [`scripts/agent-lint-feedback.mjs`, ...args], - { cwd: directory, encoding: `utf8`, input }, - ) +function runCli(cwd, script, args, input) { + const result = spawnSync(process.execPath, [script, ...args], { + cwd, + encoding: `utf8`, + input, + }) const lines = result.stderr.split(`\n`).filter(Boolean) const hook = args.includes(`--format=claude`) const reported = hook ? 2 : 1 @@ -587,28 +687,41 @@ function runCli(directory, args, input) { return reportedOracles(lines) } -/** The oracle files that each scope lints, from one repository. */ +/** What each scope reports, from one repository. */ function observedSelections(program) { - const { directory, pathOf } = repositoryOf(program) + const { launch, checkout, pathOf } = repositoryOf(program) try { const scopes = editedByScope(program) - const changed = runCli(directory, [`--format=text`]) + const own = join(checkout, `scripts`, `agent-lint-feedback.mjs`) + const launchScript = join(launch, `scripts`, `agent-lint-feedback.mjs`) + const changed = runCli(checkout, own, [`--format=text`]) // lint-staged runs the named scope once per nonempty chunk of staged // files; without files the script runs its changed scope instead. const named = scopes.named.length === 0 ? undefined - : runCli(directory, [ + : runCli(checkout, own, [ `--format=text`, ...scopes.named.map((name) => pathOf(name)), ]) - const input = JSON.stringify({ - tool_input: { file_path: pathOf(program.hookTarget) }, - }) - const hook = runCli(directory, [`--format=claude`, `--event=edit`], input) - return { changed, named, hook } + const hook = runCli( + launch, + launchScript, + [`--format=claude`, `--event=edit`], + JSON.stringify({ + cwd: checkout, + tool_input: { file_path: pathOf(program.hookTarget) }, + }), + ) + const stop = runCli( + launch, + launchScript, + [`--format=claude`, `--event=stop`], + JSON.stringify({ cwd: checkout }), + ) + return { changed, named, hook, stop } } finally { - rmSync(directory, { recursive: true, force: true }) + rmSync(launch, { recursive: true, force: true }) } } @@ -624,6 +737,14 @@ function compareSelected(observed, expected) { expect(observed).toEqual(expected) } +/** + * Every report line came from the session checkout's own rule: each line + * carries the edited rule's mark exactly when the model predicts it. + */ +function compareRuleSource(marks, marked) { + expect([...marks].filter((mark) => mark !== marked)).toEqual([]) +} + // -- 5. Reach ----------------------------------------------------------------- // // The states below are coverage of the graph, not a second model. A path is a @@ -642,6 +763,12 @@ function compareSelected(observed, expected) { // js-oracle: a JavaScript oracle file is selected. // hook-oracle, hook-support, hook-driver, hook-production, hook-rule: the // kind of file that the edit hook names. +// worktree-session: the session works in a worktree, and the hooks run the +// launch checkout's script. +// worktree-own-rule: the worktree's branch edited the rule, so its reports +// must come from its own copy. +// scratch-directory: a launch session's checkout holds a scratch directory +// under `.worktrees/`. const STATES = [ `direct-dependent`, @@ -659,6 +786,9 @@ const STATES = [ `hook-driver`, `hook-production`, `hook-rule`, + `worktree-session`, + `worktree-own-rule`, + `scratch-directory`, ] const reach = createOracleReach(PROPERTY, STATES, { minimum: 2 }) @@ -759,6 +889,12 @@ function recordStates(program) { (module) => module.name === program.hookTarget, ) states.add(`hook-${target?.kind ?? `rule`}`) + if (program.session === `worktree`) { + states.add(`worktree-session`) + if (program.ruleEdited) states.add(`worktree-own-rule`) + } else if (program.scratch) { + states.add(`scratch-directory`) + } for (const state of states) reach.reach(state) } @@ -771,10 +907,15 @@ function campaign(name) { recordStates(program) const observed = observedSelections(program) const scopes = editedByScope(program) - compareSelected(observed.changed, predictSelected(program, scopes.changed)) - if (observed.named !== undefined) - compareSelected(observed.named, predictSelected(program, scopes.named)) - compareSelected(observed.hook, predictSelected(program, scopes.hook)) + const marked = predictRuleMarked(program) + for (const scope of [`changed`, `named`, `hook`, `stop`]) { + if (observed[scope] === undefined) continue + compareSelected( + observed[scope].oracles, + predictSelected(program, scopes[scope]), + ) + compareRuleSource(observed[scope].marks, marked) + } } it(`${name}: fixed-seed campaign`, async () => { await fc.assert(fc.asyncProperty(programArbitrary, predicate), { @@ -791,7 +932,7 @@ function campaign(name) { } describe(`lint selection after an edit`, () => { - campaign(`changed, named, and edit-hook scopes`) + campaign(`changed, named, edit-hook, and stop-hook scopes`) // Every declared state must be entered by the campaigns above. afterAll(() => { if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) @@ -802,7 +943,7 @@ describe(`lint selection after an edit`, () => { // The declaration names the parts above. It runs nothing, so it follows them. declareOracle({ - model: [predictSelected], - check: [compareSelected], + model: [predictSelected, predictRuleMarked], + check: [compareSelected, compareRuleSource], grammar: [programArbitrary], })