Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/gap-cli-stream-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/oracles': patch
---

The `oracle-gaps` CLI now writes the lines it streams on stderr to `stream.log` in the output directory too, in order, cold and through `--daemon`, and the first line of a check on both streams names that file: `oracle-gaps: note log <out>/stream.log`. An agent that starts a cold check in the background can read the run's progress and its done line there while it runs. A check through `--daemon` prints its lines at the end of its check, so its progress is readable there only after it ends.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ every commit with [pkg.pr.new](https://pkg.pr.new).
to the process's stdout and stderr goes to `test-output.log` in the output
directory, which a note names; `summary.md` holds the report for reading.
A run through the daemon prints the same lines, at the end of its check.
The first line of a check, on both streams, is
`oracle-gaps: note log <out>/stream.log`: the run writes its stderr lines
to that file too, in order, so a check started in the background can be
read there while it runs and after it ends. A cold run's lines reach the
file as they are printed; a run through `--daemon` prints its lines at the
end of its check, so the file fills then.
`--help` is not a check, so its usage text has no prefix.

For contributors: two environment variables in the `ORACLE_GAPS_TEST_`
Expand Down
4 changes: 3 additions & 1 deletion packages/oracles/oracle-gaps/ending.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ export function endRun(error, out = error?.out) {
// The last line says the run refused, and where its report is once the
// run knows its output directory (the stream's done line, stream.mjs);
// it ends stdout too (index.mjs).
// A reportless refusal (another run uses the directory) writes nothing
// there: the directory's files are the other run's.
const done = write(
out,
error.reportless ? undefined : out,
results,
`Refused: ${message}\n\nFix: ${error.fix}\n`,
)
Expand Down
111 changes: 88 additions & 23 deletions packages/oracles/oracle-gaps/gap-report-model-oracle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,12 @@ const drawn = fc.record({
// The budget witness alone (`budget`) costs two 10 s confirmations per
// mutant in every CLI run of a history, so generated histories rarely
// draw it; its constructed history (the daemon recheck) always does.
// `stop` is the stop witness alone (`w_stop`), which a run that the driver
// stops needs: the capture witness's other outcome classes are not run.
witness: fc.oneof(
{ weight: 30, arbitrary: fc.constantFrom(false, true, `capture`) },
{ weight: 1, arbitrary: fc.constant(`budget`) },
{ weight: 1, arbitrary: fc.constant(`stop`) },
),
authored: fc.option(
fc.record({
Expand Down Expand Up @@ -690,6 +693,39 @@ const drawn = fc.record({
// A second run with `--only`, of one owned function's mutants: its
// baseline runs only their owners (law 14). It costs a run of its own.
onlyRun: fc.constantFrom(false, false, false, false, false, true),
// Where the run writes: the output directory `--out` names (`explicit`), or
// the one the CLI picks when `--out` is left out (`default`, args.mjs), whose
// stream log, results, and done line must still agree (law 15).
outMode: fc.constantFrom(`explicit`, `explicit`, `explicit`, `default`),
// The run's `<out>/stream.log` is a directory, so the log cannot be written
// (a problem the author can fix: the run refuses before it checks).
logBlocked: fc.constantFrom(
false,
false,
false,
false,
false,
false,
false,
false,
true,
),
// A second run starts on the first run's output directory while the first
// is live (the coordinator's decision of 2026-11-13 on F4): the second
// refuses, and the first's stream log stays its own.
overlap: fc.constantFrom(
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
),
// Oracle file a names its own Vitest environment in a pragma
// (`// @vitest-environment`), another than the config's, and fails to load
// outside it: every copy of it must run in that environment too.
Expand Down Expand Up @@ -2477,6 +2513,7 @@ function resolveHistory(history) {
// so only histories of the `budget` mode carry it.
(history.witness === true && w.id !== `w_slowpair`) ||
(history.witness === `capture` && captureWitness.has(w.id)) ||
(history.witness === `stop` && w.id === `w_stop`) ||
(history.witness === `budget` && w.id === `w_slowpair`),
)
.map((w) => ({
Expand Down Expand Up @@ -2837,6 +2874,12 @@ function resolveHistory(history) {
depSubpath: history.depSubpath === true,
unbuiltDep: history.unbuiltDep === true,
budget: history.budget ?? `two`,
outMode: history.outMode ?? `explicit`,
// Both name the run's `--out` directory before it starts, so they need
// an explicit one (a default directory is chosen by the CLI).
logBlocked:
history.logBlocked === true && outModeOf(history) === `explicit`,
overlap: history.overlap === true && outModeOf(history) === `explicit`,
stopRuns: history.stopRuns ?? [],
onlyRun: history.onlyRun === true,
pragma: history.pragma === true,
Expand Down Expand Up @@ -4083,10 +4126,23 @@ function refusal(plan) {
if (plan.fault === `brokenVitest`) return { cause: `vitest`, runtime: true }
if (plan.fault === `projects`)
return { cause: `test.projects`, runtime: true }
// A stream log that cannot be written is refused before the check starts
// (the CLI opens the log before its first line, index.mjs), so its stdout
// owes its done line alone.
if (plan.logBlocked === true)
return { cause: `the stream log`, arguments: true }
if (plan.fault === `badEntry`) return { cause: `${SOURCE}#nope` }
// An owns entry that names a missing file (a test file's declaration) is
// found by the analysis, after the check has started, so this refusal
// owes the stream log's note. The argument-stage case, a `--source` that
// is not a file of the package, is the static oracle's `absentSource`.
if (plan.fault === `missingSource`) return { cause: `src/missing.ts` }
if (plan.fault === `nonLiteral`) return { cause: `b.test.ts` }
if (plan.fault === `outsideSource`) return { cause: `outside the package` }
// A `--source` outside the package is an argument the CLI refuses before
// it starts a check (the argument rules that the static oracle judges),
// so its stdout has no line that names a stream log.
if (plan.fault === `outsideSource`)
return { cause: `outside the package`, arguments: true }
// The analysis reads the test files that the package's Vitest config
// includes (index.mjs, step 2), so a config that does not load is named
// before anything the analysis would find. The cause names the config
Expand Down Expand Up @@ -4183,6 +4239,7 @@ const declarationFaults = [
`nonLiteral`,
`outsideSource`,
]
const outModeOf = (history) => history.outMode ?? `explicit`
const reachesRun = (history) =>
!declarationFaults.includes(history.fault ?? `none`)

Expand Down Expand Up @@ -4235,21 +4292,22 @@ function model(plan) {
// fault, a Vitest that does not load, a config that does not load, and a
// config that defines test.projects, which a loaded context shows. Every
// other run names it once, refused or not.
const configLines = [
`outsideSource`,
`brokenVitest`,
`configThrows`,
`configNoDefault`,
`configNull`,
`configSyntax`,
`projects`,
].includes(plan.fault)
? []
: [
plan.configPath === undefined
? `oracle-gaps: config none, Vitest's default discovery`
: `oracle-gaps: config ${plan.configPath}`,
]
const configLines =
[
`outsideSource`,
`brokenVitest`,
`configThrows`,
`configNoDefault`,
`configNull`,
`configSyntax`,
`projects`,
].includes(plan.fault) || plan.logBlocked === true
? []
: [
plan.configPath === undefined
? `oracle-gaps: config none, Vitest's default discovery`
: `oracle-gaps: config ${plan.configPath}`,
]
if (refused) return { refused, configLines }
const sites = sitesOf(plan)
const owners = ownersOf(plan)
Expand Down Expand Up @@ -5490,8 +5548,13 @@ function endingOf(exit, stderr, out, name = (text) => text, stdout = ``) {
* The first way a run's ending breaks the refusal law, or undefined. The
* cause must name `cause`; with `outKnown`, the run knew its output
* directory, so the block names it and the directory holds the refusal.
* `logNote` is the line that names the run's stream log, owed first on
* stdout when the run refused after it started a check, and undefined for
* a refusal of its arguments, before any check (the maintainer's decision
* of 2026-11-12 that every run that starts a check names its stream log
* first).
*/
function refusalBreach(ending, { cause, outKnown }) {
function refusalBreach(ending, { cause, outKnown, logNote }) {
if (ending.exit !== 2) return `the refusal exited ${ending.exit}, not 2`
if (ending.stackFrames) return `the refusal printed a stack trace`
if (ending.cause === undefined)
Expand All @@ -5510,12 +5573,14 @@ function refusalBreach(ending, { cause, outKnown }) {
!/^refused(?:, report .+)?$/.test(ending.done)
)
return `the refusal's last line is not \`oracle-gaps: done refused\``
// The done line ends stdout too, as its only line.
if (
ending.stdout.length !== 1 ||
ending.stdout[0] !== `oracle-gaps: done ${ending.done}`
)
return `the refusal's stdout is ${JSON.stringify(ending.stdout)}, not its done line`
// The done line ends stdout too, as its only line after the line that
// names the stream log, if the run started a check.
const owedStdout = [
...(logNote === undefined ? [] : [logNote]),
`oracle-gaps: done ${ending.done}`,
]
if (JSON.stringify(ending.stdout) !== JSON.stringify(owedStdout))
return `the refusal's stdout is ${JSON.stringify(ending.stdout)}, not ${JSON.stringify(owedStdout)}`
if (!outKnown) return undefined
if (!ending.namesOutput)
return `the refusal does not name its report in its output directory: done ${ending.done}`
Expand Down
Loading
Loading